Skip to content

lit-ui-router-ssr ​

NPM VersionGitHub Release

lit-ui-router-ssr turns a ui-router-server mount table into an emitted static site. prerender() asks the table for a verdict per path and makes each one an artefact: a shell verdict becomes <subpath>/index.html, a redirect becomes a host rules line and no page, and the mount's otherwise projection becomes the 404 document.

The render itself is why the package exists. It owns the @lit-labs/ssr call and the router hand-off — provideRouter on the render root, withRouterSync around the render — so a consumer never imports the pre-1.0 renderer or re-derives the incantation, and a template's <ui-router> descendants and its srefHref attribute directives both read the same router.

Installation ​

bash
npm install lit-ui-router-ssr
# or
pnpm add lit-ui-router-ssr

lit-ui-router, ui-router-server, @lit-labs/ssr, @lit-labs/ssr-client, lit, and @uirouter/core are peer dependencies. @lit-labs/ssr is the server half and @lit-labs/ssr-client the client half, so a bundle takes one or the other, never both. @lit-labs/ssr-client is optional: install it when you import lit-ui-router-ssr/client.

What this package owns ​

  • The render call. provideRouter(root, router) once, then withRouterSync(router, () => collectResultSync(render(template, { eventTargetStack: [root] }))) per page — the pairing the Server-Side Routing guide derives by hand, applied for you.
  • The emit loop. Verdict to file name, redirect to rules line, tally, and a warning for every path that matched nothing.
  • The host rules file. _redirects by default, every generated line paired with and without a trailing slash. No SPA catch-all is ever written: one turns every 404 into a 200.

Path enumeration, the html document, and <title> stay with the caller — paths, document(), and an async renderShell() are the seams for them. settle() drives the router to each path inside renderShell().

Quick start ​

ts
import { prerender, settle } from 'lit-ui-router-ssr';

const result = await prerender({
  mounts,
  router,
  outDir: 'dist',
  paths: ['/', '/sheet/7B', '/legacy'],
  extraRules: [{ from: '/megacanvas', to: '/megacanvas.html', status: 301 }],
  renderShell: async (_verdict, { path }) => {
    await settle(router, path);
    return page();
  },
  document: (body, { path }) => fillShell(titles.get(path), body),
});

console.log(result.tally); // { shell: 2, redirect: 1, notFound: 0, document: 1 }

renderShell returns a template and this package renders it; return a string and it is written as-is. dryRun: true plans everything and writes nothing.

Settling the router on each path ​

A server render reads the router only once its transition has landed, resolves included. settle(router, path) sets the url, syncs the router to it, and resolves with the transition that landed, so the synchronous render after it reads every resolve. A redirectTo chain settles on its final state, and a path the router already stands on resolves at once.

A page fails to land in three ways, and each rejects rather than hangs:

  • No rule matches. A url no state claims, on a router with no otherwise rule, rejects with an Error naming it. An otherwise rule is itself a match and settles on its state.
  • A resolve fails. The promise rejects with an Error whose cause is the transition's Rejection, the resolve's error in its detail. Core's defaultErrorHandler still logs it.
  • Nothing lands in time.timeout bounds the wait, 10_000 ms by default; 0 waits without a limit.

settle() never calls router.start(), which runs once per router: it drives urlService directly, so a page loop calls it once per path on the same router, started or not.

Verdict to artefact ​

VerdictWhat is emitted
shell<subpath>/index.html, rendered through renderShell and document
redirecta rules line (from to status), no page
notFoundnothing, and the path is listed in result.warnings
the otherwise probethe 404 document, 404.html by default, rendered like any shell

PrerenderResult carries all of it: pages lists every artefact in enumeration order, rules every line the host file carries, tally the counts by kind, warnings the paths that matched no route and had no projection to fall back on, and root the render's event target.

Where files land ​

Files go through node:fs, imported lazily on first write, so the specifier never enters the static module graph. Pass write to emit into memory or a virtual fs instead — it receives outDir already joined on.

The rules file follows the same seam: rules: '_redirects' (the default) writes the Cloudflare Pages / Netlify file, 'none' writes nothing and leaves result.rules to you, and a function receives the lines and writes whatever your host reads.

Static hosts add a trailing slash ​

A shell page lands at <subpath>/index.html. Cloudflare Pages, Netlify, and S3-style hosts serve that file for /sheet/7B by answering 308 onto /sheet/7B/, so the client boots at the slashed url. @uirouter/core's default strictMode refuses the trailing slash, and the app boots into its not-found state over the correct prerendered page. A host with directory-index redirects makes the pairing mandatory — relax both sides:

ts
// the browser router
router.urlService.config.strictMode(false);

// the mount prerender() resolves against
const mounts = { '/': { routes, config: { strict: false } } };

The mount half holds at build time too: prerender() takes every verdict from it, and the live server answers from the same table. A preview server such as vite preview serves the file without the redirect, so only the deployed site shows the failure. The development build warns once when the first page's slashed spelling does not resolve to the same shell.

The Altitude Atlas, where /sheet/7B comes from, runs both halves on Cloudflare Pages: its client sets strictMode(false) and its mount compiles strict: false.

One render at a time ​

withRouterSync is a module slot, so renders run sequentially — there is nothing to parallelise. The uninstall from provideRouter runs in a finally, so a throwing render leaves no listener behind.

The render root is yours if you want it: pass root and prerender provides the router on that target, so you can attach context providers of your own before the call. It comes back on the result either way.

Property bindings on the server ​

Take a card that receives a post by property and draws it in its own render():

ts
import { html, LitElement } from 'lit';

type Post = { title: string; summary: string };

class XCard extends LitElement {
  static properties = { post: { attribute: false } };
  declare post: Post;
  render() {
    return html`<h2>${this.post.title}</h2>
      <p>${this.post.summary}</p>`;
  }
}
customElements.define('x-card', XCard);

A routed template that feeds it by property, html`<x-card .post=${post}></x-card>`, serves the card empty:

text
<!--lit-part vYJeArn6Pos=--><!--lit-node 0--><x-card  defer-hydration></x-card><!--/lit-part-->

@lit-labs/ssr writes a .prop=${…} binding as its part marker and nothing else — no attribute, no child, no value. Setting the property is an element renderer's job, and elementRenderers defaults to [UiViewRenderer], which answers for ui-view alone. The card has no renderer, so nothing on the server sets post or runs its render(): the card first draws on the client, once hydration sets the property, and the served page shows an empty tag where it stands.

Anything the served page has to show arrives as an attribute, as children, or through a renderer for the element. The shape that keeps one template on both sides writes the content as the card's children, binds the property over it, and lets the card project them through a <slot>:

ts
class XCard extends LitElement {
  static properties = { post: { attribute: false } };
  declare post: Post;
  render() {
    return html`<slot></slot>`;
  }
}

const card = (post: Post) =>
  html`<x-card .post=${post}>
    <h2>${post.title}</h2>
    <p>${post.summary}</p>
  </x-card>`;

The server emits the children, the hydrate() walk described under The hydration model adopts them as the same child part and sets post, and the card keeps the data for its behaviour while the template owns what shows.

Write the children on both sides. A branch on isServer that writes them on the server alone looks like a way to serve the content without drawing it twice:

ts
import { html, isServer } from 'lit';

// ❌ two templates: the client cannot adopt what the server drew
const card = (post: Post) =>
  isServer
    ? html`<x-card .post=${post}>
        <h2>${post.title}</h2>
        <p>${post.summary}</p>
      </x-card>`
    : html`<x-card .post=${post}></x-card>`;

The served page carries the children, under a part marker that names the template that drew them:

text
<!--lit-part EdxohtX2HMw=--><!--lit-node 0--><x-card  defer-hydration><h2><!--lit-part-->Hello<!--/lit-part--></h2><p><!--lit-part-->A first post.<!--/lit-part--></p></x-card><!--/lit-part-->

On the client isServer is false, so the enclosing ui-view hydrates the second template, whose own marker would read vYJeArn6Pos=, against that one. The digests differ, hydrate() throws on the mismatch, and the view drops everything the server drew inside it and renders cold. The served card and its children go, the client draws the empty card in their place, and in development the console warns that the element could not adopt the server render.

The other answer is a renderer for the card. LitElementRenderer from @lit-labs/ssr, passed beside UiViewRenderer, sets the property on the server and draws the card's render() into a declarative shadow root:

ts
import { LitElementRenderer } from '@lit-labs/ssr';
import { prerender, UiViewRenderer } from 'lit-ui-router-ssr';

await prerender({
  // …
  elementRenderers: [UiViewRenderer, LitElementRenderer],
});

With the first card, the one that draws post in its own render(), the same property binding now serves the card filled:

text
<!--lit-part vYJeArn6Pos=--><!--lit-node 0--><x-card  defer-hydration><template shadowroot="open" shadowrootmode="open"><!--lit-part HdSxZ92CImA=--><h2><!--lit-part-->Hello<!--/lit-part--></h2><p><!--lit-part-->A first post.<!--/lit-part--></p><!--/lit-part--></template></x-card><!--/lit-part-->

The children stay where the card's render() put them, in a shadow root the browser attaches as it parses, and hydration adopts them there. LitElementRenderer does the same for every LitElement on the page, whether or not it has anything to show on the server, which is the cost the default avoids.

Development and production builds ​

Like lit-ui-router, this package ships two builds and bundlers pick between them through the development export condition — see Development & Production Builds for the mechanism.

Three warnings come from prerender(). A path that verdicts notFound with no otherwise projection to fall back on logs a console warning naming that path, because nothing was emitted for it; result.warnings carries the same list in both builds. A mount that refuses the first page's trailing-slash spelling logs one warning naming that page — see Static hosts add a trailing slash. A registry that holds no <ui-view> when the call starts logs one warning, because every view on those pages would render empty. Plain node resolves the production build; node --conditions=development resolves this one.

Registering the elements ​

A prerendered page needs the served <ui-view>, and lit-ui-router-ssr/register is the one import that defines it. Three shapes cover every app.

A cold app ​

It imports lit-ui-router and changes nothing. It never draws a document on the server, so it never needs this package on the client.

A prerendered app ​

It imports lit-ui-router-ssr/register in place of lit-ui-router, ahead of anything else that registers <ui-view>. That entry defines <ui-router> from core and <ui-view> with withServedRender applied to core's UiView. Every value the app takes from the router — UIRouterLit, srefHref, srefActiveClass, srefAriaCurrent — comes from lit-ui-router/pure, which registers nothing; the root entry registers the plain <ui-view> as a side effect, and imported after this one it warns that the tag is already defined. Then the router boots and one call adopts the page:

ts
import 'lit-ui-router-ssr/register';
import { UIRouterLit, srefHref } from 'lit-ui-router/pure';
import { hydrateRoot } from 'lit-ui-router-ssr/client';

router.start();
await booted; // the first successful transition
const release = hydrateRoot(root, page(router));

A <ui-view> another class already defined throws, naming both ways out.

The build-time entry that calls prerender() imports lit-ui-router-ssr/register too, once the DOM shim has finished installing. The shim module awaits at its top level, so a static import beside it evaluates first: lit's Node build installs a registry of its own, the elements are defined there, and the shim then replaces globalThis.customElements with an empty one. Import the shim statically and everything that defines or reaches an element by dynamic import:

ts
import '@lit-labs/ssr/lib/install-global-dom-shim.js';

await import('lit-ui-router-ssr/register');
const { UIRouterLit } = await import('lit-ui-router/pure');
const { prerender, settle } = await import('lit-ui-router-ssr');
const { page } = await import('./views.js');

The atlas's app/prerender.ts is a complete entry in this shape.

Preloading the shim with node --import @lit-labs/ssr/lib/install-global-dom-shim.js finishes it before the entry's graph starts, so static imports hold there too. UiViewRenderer draws the served class, and a <ui-view> nothing defined on the server renders as an inert element with an empty part pair — no error, and every page's body missing. In development prerender() warns when the registry holds no <ui-view>.

An app with its own registry ​

It imports lit-ui-router/pure, which registers nothing, and defines the served class under a tag of its own:

ts
import { UiView } from 'lit-ui-router/pure';
import { withServedRender } from 'lit-ui-router-ssr/client';

customElements.define('app-view', withServedRender(UiView));

The result extends core's UiView, so an enclosing view adopts it as a parent. UiViewRenderer answers for the ui-view tag, so a tag of your own needs a renderer of your own.

lit-ui-router-effect from 0.1.1 and lit-ui-router-mobx from 1.0.2 import lit-ui-router/pure, so they register nothing and compose with any of the three.

The hydration model ​

A served <ui-view> — the class lit-ui-router-ssr/register defines the tag with — arrives asleep. The render passes deferHydration, so every custom element on the page carries Lit's defer-hydration attribute, and while it is there the view renders nothing and holds the nodes the server drew. The client boots the router first, then calls hydrateRoot, which provides an adopter under the container with core's provideContext and runs one hydrate() walk over it:

ts
import { hydrateRoot } from 'lit-ui-router-ssr/client';

const release = hydrateRoot(root, page(router));

prerender() opens every page it renders from a template on a hydration signature: a JSON data block ahead of the render, carrying this package's version, the name of the state the router stood on, and that state's url parameter values. It never executes, hydrate() walks comments only and reads past it, and every < and > in it is written \u003c and \u003e, so no value can close it.

text
<script type="application/json" data-lit-ui-router-ssr>{"version":"0.2.0","state":"sheet","params":{"num":"7B"}}</script>

hydrateRoot reads it before it touches the document, and returns false over an emptied container when it finds nothing to adopt, so the caller's render() draws the page once:

  • No signature. A cold client render, a dev server, a page from an earlier prerender(), or a page whose renderShell returned a string.
  • A signature from another release line. Below 1.0 a minor breaks, so a document adopts only on the client's own minor; from 1.0, its own major. The development build warns, naming both versions.
  • A signature with no render marker after it. A minifier that strips html comments keeps the block and drops the markers hydrate() reads. The development build warns.

A container hydrateRoot adopts keeps its block.

readHydrationSignature returns the parsed HydrationSignature, or null when the container holds none or one that does not parse to an object with a string version. Only version is typed, so a boot that compares the state the document was drawn for with the one it booted into narrows state and params first.

Every uiViewSlot that walk reaches wakes the <ui-view> it sits in, and the waking view requests adoptUiViewContext over the standard context-request event and hands itself to whichever provider answers. The mechanics — the boot sequence, the marker protocol, and what a view with no provider above it does — are in the API reference and in the package README.

Each served view reports what its wake came to, once, parent first, in production as in development. The view dispatches a bubbling, composed ui-view:adopt event whose detail carries an AdoptOutcome: adopted when its served nodes are the live ones, fell-back when a mismatch or a lost pair dropped them for a cold render, with the cause in error, and none when there was nothing to adopt. hydrateRoot's onAdopt option receives the same reports for every view its walk reaches, so a consumer that only calls hydrateRoot needs no listener:

ts
hydrateRoot(root, page(router), {
  onAdopt: (view, outcome, error) => {
    if (outcome === 'fell-back') report(view, error);
  },
});

How this compares ​

Two axes separate the hydration models in circulation: where the code that wakes the markup comes from — imported from the renderer, or provided from outside it — and how much it wakes at once, the whole tree or one boundary on demand.

ModelWhere the wake code comes fromScopeShipped by the framework
Whole tree, in-rendererimported from the renderer — React hydrateRoot(), Vue createSSRApp().mount(), Solid hydrate()the pageyes
Whole tree, provideda provider or a global patch — Angular provideClientHydration(), Lit's lit-element-hydrate-supportthe pageopt-in
Per boundary, in-rendererthe renderer schedules it — React Suspense selective hydration, Nuxt <NuxtIsland>one boundaryyes
Per boundary, provideda directive or a context provider — Astro client:*, Angular @defer (hydrate on …), this packageone boundaryopt-in

Solid pairs its walk with data-hk hydration keys in the markup; Angular's provider arrives through DI, while Lit's own @lit-labs/ssr-client support is a patch of LitElement's prototype — opt-in, but global once imported. Astro sits at the far end of the provided column: the island's client:* directive is the whole contract, and the framework inside the island never sees it.

Qwik is the outlier on both axes. It resumes rather than hydrates, so there is no wake pass at all: the served markup and the serialized state both survive into the client, and a handler is fetched when its event fires. This package keeps only the markup; the router rebuilds its state on the first transition, which is what booting before hydrateRoot() waits for.

In that grid lit-ui-router-ssr sits in the per-boundary, provided cell. Each <ui-view> is an island whose trigger is a route match rather than viewport or idle. The code that wakes it is provided, not imported, so any context-request provider — an @lit/context provider, a test harness, a nested app — can scope or replace the adopter, and the element side reuses Lit's defer-hydration contract unchanged. All of it is in this package: lit-ui-router-ssr/register defines <ui-view> with the served class, so an app that never prerenders carries none of it.

Further reading ​