Skip to content

Prerender with Vite ​

One plugin option and one script turn vite build into a static site: one HTML page per route, a 404.html, and a _redirects file for the host. The same route table the app runs decides which path gets which, so a static host answers a deep link, a redirect and a typo the way the server would.

Three terms carry the rest of this page:

  • The route table is the mounts object ui-router-server compiles. Each key is a mount, the url prefix an app is served under (/ here), and holds that app's route names and urls as plain data, with no components, so vite.config.ts and a server can import it.
  • A verdict is the route table's answer for one path: a page, a redirect, or no route at all.
  • The prerender script is the module the plugin runs after the client build. Its default export receives the build's output and returns what it wrote.

The two files ​

The app below keeps its route table in src/routes.ts: a home state at /, about at /about, legacy at /legacy redirecting to about, and a url-less notFound state that the mount's otherwise rule sends every unmatched path to. Its src/app.ts holds the Lit side: states, the same names and urls with their components, and page(router), the template src/main.ts renders in the browser. That template's <ui-view> holds uiViewSlot() from lit-ui-router-ssr/client, the hole the server render fills with the routed view.

vite.config.ts adds one option to serverRouterPlugin:

ts
import { createServerRouter } from 'ui-router-server';
import { serverRouterPlugin } from 'ui-router-server/vite';
import { defineConfig } from 'vite';

import { mounts } from './src/routes.ts';

export default defineConfig({
  plugins: [
    serverRouterPlugin(createServerRouter({ mounts }), {
      prerender: { entry: './src/prerender.ts' },
    }),
  ],
});

prerender: './src/prerender.ts' is shorthand for the same option.

src/prerender.ts is the prerender script. definePrerender types its default export and returns it unchanged, as Vite's defineConfig does. The plugin calls it with site:

  • site.outDir: Vite's output directory, absolute.
  • site.shellHtml: the built index.html, as a string.
  • site.write: writes a file, creating its directory.
ts
import '@lit-labs/ssr/lib/install-global-dom-shim.js';

import { definePrerender } from 'ui-router-server/vite';

// Loaded once the DOM shim has installed, so the elements land in its registry.
await import('lit-ui-router-ssr/register');
const { UIRouterLit } = await import('lit-ui-router/pure');
const { installServerLocation } = await import('ui-router-server/location');
const { prerender, settle } = await import('lit-ui-router-ssr');
const { mounts } = await import('./routes.ts');
const { page, states } = await import('./app.ts');

const router = installServerLocation(new UIRouterLit());
for (const state of states) router.stateRegistry.register(state);
router.urlService.rules.otherwise({ state: 'notFound' });

export default definePrerender((site) =>
  prerender({
    ...site,
    mounts,
    router,
    paths: ['/', '/about', '/legacy'],
    renderShell: async (_verdict, { path }) => {
      await settle(router, path);
      return page(router);
    },
    document: (body) => site.shellHtml.replace('<!--app-->', body),
  }),
);

paths lists every path to visit. renderShell moves the router to the path and returns the page's template, and document puts the rendered body into the built index.html, where the app's container holds a marker:

html
<div id="app"><!--app--></div>

lit-ui-router-ssr explains the import order: the shim must finish installing before anything defines an element.

What vite build writes ​

Next to Vite's own assets/, dist/ gains:

  • index.html: the home page, in place of the built shell.
  • about/index.html: the about page.
  • 404.html: the notFound page, which a static host serves for any path with no file.
  • _redirects: /legacy /about 302, plus the same line for /legacy/.

/legacy gets no page: its verdict is a redirect, so it becomes a line in _redirects, the file Cloudflare Pages and Netlify read. Those hosts serve about/index.html at /about/, so both the route table and the browser router need to accept the trailing slash.

How it runs ​

Four parties share one vite build. Vite runs the client build and owns dist/. ui-router-server owns the plugin and the verdicts, and never imports Lit. Your app owns the route table, the paths, the router and the page. lit-ui-router-ssr owns the Lit render. The tinted boxes are lit-ui-router's part; the tinted files are the ones it adds to dist/.

Who owns what at build timeVitethe build, and dist/ui-router-serverthe plugin and the verdicts; no Litlit-ui-router-ssrthe Lit renderyour approute table, paths, router, pagevite buildthe client build, firstserverRouterPluginloads the prerender scriptsrc/prerender.tsdefinePrerender(…)site: { outDir, shellHtml, write }prerender({ …site, mounts, router, paths })prerender()renders each page with Litwith its render, per pageprerender()a verdict for every pathsrc/routes.tsthe route table: mountsa verdict per pathwriteno route → build failsa listed path no rule claimsdist/assets/ · index.htmlindex.htmlabout/index.html404.html_redirectsvite build, with prerender: './src/prerender.ts'tinted: lit-ui-router's part · the files it adds

lit-ui-router-ssr's prerender() supplies the Lit render and hands the rest to ui-router-server's, which visits the paths, asks the route table for each verdict and writes the files. The diagrams below follow each step.

What the plugin hands the script ​

The plugin runs once the client build has written dist/. It reads the built index.html as shellHtml, loads the prerender script and calls its default export with site. The script spreads site into prerender() and returns the result unchanged: the pages, the _redirects lines, the warnings and the delegated paths. A non-empty warnings fails the build; otherwise the plugin logs a summary line.

What the plugin hands the prerender scriptvite buildserverRouterPluginlit-ui-router-ssrprerender scriptclient build doneread dist/index.htmlbecomes shellHtmlsite: { outDir, shellHtml, write }prerender({ ...site, mounts, router,paths, renderShell, document }){ pages, rules, warnings, delegated }the result, unchangedwarnings? the build failssummary logged

A page ​

/about is the whole render path. The route table's verdict is a page, so ui-router-server asks lit-ui-router-ssr to render it. Your renderShell settles the router on the path and returns the page's template, Lit renders it to html, and your document puts that body into shellHtml in place of the marker. The finished document is written as about/index.html.

A page: /aboutui-router-serverroute tablelit-ui-router-ssryour appverdict for /abouta page: about, mount /render(verdict, { path, file })renderShell(verdict, { path })settle(router, path)then page(router)the page's templateLit render → body htmldocument(body)shellHtml with <!--app--> filledthe page documentwrite about/index.html

A redirect ​

/legacy never reaches Lit. Its verdict is a redirect, so it becomes a line in _redirects, plus the same line for /legacy/, and no page is written.

A redirect: /legacyui-router-serverroute tablelit-ui-router-ssryour appverdict for /legacya redirect: /about, 302/legacy /about 302a _redirects line, and one for /legacy/nothing rendered, no page written

The 404 page ​

After the listed paths, ui-router-server asks the route table about a path no route claims under the mount. The otherwise rule answers with the notFound page carrying status 404, which renders like any other page and is written once, as 404.html.

The 404 page, onceui-router-serverroute tablelit-ui-router-ssryour appafter every path in pathsverdict for a path no route claimsa page: notFound, status 404 (otherwise)render(verdict, { path, file })renderShell, then documentthe page documenthtmlwrite 404.html

A path with no route ​

A path in paths that matches no route, with no otherwise rule to fall back on, gets no file and lands in warnings. The plugin turns that into an error naming the path, and vite build fails.

A listed path with no route fails the buildvite buildserverRouterPluginui-router-serverroute tableverdict for /nowhereno routewarnings: ['/nowhere']no file for itthe resultthrow: no route matched /nowherevite build fails

Details ​

Loading the script. The plugin runs after every other plugin has finished the client build, and loads the prerender script through Vite's module runner with the project's root and mode but without the app's plugins. The app's own modules go through Vite; every dependency loads from node_modules as it would under node src/prerender.ts.

vite preview. The plugin's middleware still answers redirects and paths with no route, so /legacy answers 302. Every other path gets the file the build wrote for it, 404.html for a path the otherwise rule catches, and index.html for a path the build wrote no page for. A serveShell passed to the plugin replaces this.

vite preview after the buildBrowservite previewserverRouterPlugindist/GET /abouta page → about/index.htmlrewritten request200 about/index.htmlGET /legacya redirect302 Location: /aboutGET /nowhereotherwise → 404.htmlrewritten request404 404.htmlthe middleware runs before vite preview serves a file

Other renderers. The prerender script picks the prerender() it calls. For an app that does not render with Lit, call prerender from ui-router-server/prerender with a render of your own, which receives each page verdict and returns the file's text.

Delegating states on Workers. A path that matches a delegating state gets no file, and the result lists it in delegated. On Cloudflare Workers, list those paths in assets.run_worker_first so the Worker answers them before the asset layer does.