Skip to content

Render Lit on the server

With @lit-labs/ssr, a Vite dev server can render Lit components to HTML with declarative shadow roots, and the browser hydrates them in place. The plugin runs on both halves: it hot-patches the hydrated components, keeps the server’s next render in step with your edits, and lets sheet modules render on the server. The SSR example is a complete setup, which you can also run on StackBlitz.

Run Vite in middleware mode behind your own HTTP server, and render each page through Vite’s SSR module graph, so an edit reaches the next render without a restart.

server.mjs
import {createServer as createHttpServer} from 'node:http';
import {readFile} from 'node:fs/promises';
import * as path from 'node:path';
import {fileURLToPath} from 'node:url';
import {createServer} from 'vite';
const here = path.dirname(fileURLToPath(import.meta.url));
const OUTLET = '<!--ssr-outlet-->';
/**
* @param {object} [options]
* @param {string} [options.root] Where `index.html` and `src/` are.
* @param {import('vite').InlineConfig} [options.vite] Merged into Vite's config.
* @param {number} [options.port]
* @param {string} [options.host]
* @param {import('node:http').Server} [options.http] The HTTP server to serve
* from, for a caller that hands it to Vite as well.
*/
export const startSsrServer = async ({
root = here,
vite: extra = {},
port = 5180,
host = 'localhost',
http = createHttpServer(),
} = {}) => {
const vite = await createServer({
root,
appType: 'custom',
...extra,
// `host` too: with no HTTP server of Vite's own, the DevTools dock opens
// a side socket on `server.host`, and the page dials it at the host the
// page came from. Left to its `localhost` default it can bind `::1`
// while the page is on 127.0.0.1, and the dock never connects.
server: {...extra.server, middlewareMode: true, host},
});
const renderHtml = async (url) => {
const template = await vite.transformIndexHtml(
url,
await readFile(path.join(root, 'index.html'), 'utf8')
);
const {renderPage} = await vite.ssrLoadModule('/src/entry-server.ts');
return template.replace(OUTLET, await renderPage());
};
http.on('request', (req, res) => {
vite.middlewares(req, res, async () => {
// Anything Vite didn't serve is a page.
try {
const html = await renderHtml(req.url ?? '/');
res.writeHead(200, {'content-type': 'text/html'}).end(html);
} catch (error) {
vite.ssrFixStacktrace(error);
console.error(error);
res.writeHead(500, {'content-type': 'text/plain'});
res.end(String(error?.stack ?? error));
}
});
});
await new Promise((resolve) => http.listen(port, host, resolve));
const address = http.address();
const origin = `http://${host}:${address.port}`;
return {
vite,
origin,
close: async () => {
await vite.close();
await new Promise((resolve) => http.close(resolve));
},
};
};
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const {origin} = await startSsrServer();
console.log(`Lit SSR example at ${origin}/`);
}

The server entry renders the page. Import the components there too, so the server knows them.

src/entry-server.ts
import {render} from '@lit-labs/ssr';
import {collectResult} from '@lit-labs/ssr/lib/render-result.js';
import {page} from './page.js';
/**
* The page's components as HTML with declarative shadow roots, for
* `server.mjs` to put in `index.html`.
*/
export const renderPage = (): Promise<string> => collectResult(render(page()));

The client entry loads Lit’s hydrate support before anything imports Lit, then the components.

src/entry-client.ts
import '@lit-labs/ssr-client/lit-element-hydrate-support.js';
import './ssr-counter.js';
import './ssr-deferred.js';
import './ssr-themed.js';
document.querySelector('#wake')?.addEventListener('click', () => {
document.querySelector('ssr-deferred')?.removeAttribute('defer-hydration');
});
  • Hot patches on hydrated components. An edit patches a hydrated component in place, keeping its state and its element, as on a page the browser rendered.
  • The next server render. Vite re-runs the edited module for the next render, which defines the element again. The plugin lets the server’s registry take the new class, so a reload shows the edit instead of failing because the tag is already defined.
  • defer-hydration. An element held back by defer-hydration that you edit before it wakes keeps the server’s HTML on screen, then renders the edited template when the attribute goes.
  • Sheet modules. A ?css-sheet import no longer throws in Node. Its css is written into the component’s declarative shadow root, so the page arrives styled, and once the component hydrates it adopts the real sheet, so css edits hot-swap it. See Share one stylesheet with ?css-sheet.
  • A page older than your edit. If the HTML predates an edit, the component renders in the browser once instead of failing to hydrate and later showing twice.
  • Hydration state in the DevTools. The Components tab marks a server-rendered component deferred, not hydrated or hydrated.

Build the client and the server entry separately (vite build, then vite build --ssr src/entry-server.ts) and serve them without Vite, as the example’s preview.mjs does. A ?css-sheet import is inlined in the server build, so the rendered HTML still arrives styled.

There is no dev server then, so no HMR and no DevTools dock. The Lit panel can still attach through lit-devtools dev: put its script tag first in <head>, before the client entry hydrates anything, and the Components tab shows the tree and each element’s hydration state.

The plugin leaves the server’s own modules untransformed: no HMR wrappers, no source stamps and no timeline events on the server.

Lit’s hydration expects the client’s template to match the server’s HTML. A page loaded in the instant between an edit reaching the server and reaching the browser carries HTML from the old template. The plugin catches the mismatch and renders that component in the browser instead, with a console warning that names it. Nothing is lost, since a component has no state before it hydrates, but it is a client render, not a hydration.