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.
Set it up
Section titled “Set it up”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.
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.
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.
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');});What works
Section titled “What works”- 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 bydefer-hydrationthat 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-sheetimport 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.
In a production build
Section titled “In a production build”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.
Limits
Section titled “Limits”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.