Prerendering components at build time
Prerender your components to HTML when you build your site, so the page shows them before any JavaScript loads. Lit SSR does the rendering, and in the browser Lit hydrates each component: it takes over the HTML that's there instead of rendering it again. This guide covers what runs at build time, the rules a component must follow, and how to set it up with Vite.
Firelight components are Lit components, so Lit SSR (@lit-labs/ssr, which runs in Node) can
render them. This site is built that way. Its pages are Markdown, turned into HTML at build time by
a renderer written in F#, and every demo on them is in the page's HTML before its script loads.
Prerendering here means at build time only. No server renders pages on request or keeps track of what a component does in the browser, and as Lit SSR runs in Node, a .NET server can't run it. For an application, rendering in the browser is the default. Prerender the pages people should see before the JavaScript arrives, such as documentation, a landing page or an application's loading screen.
What runs at build time
Lit SSR renders a component in Node by creating it and asking it for its template. It runs:
- the code at the top level of each module it imports, such as
defineElementcalls; - the constructor, including
letbindings and the controllers they create; willUpdateandrender, with the properties that attributes and the parent's template set.
It doesn't run connectedCallback, shouldUpdate, update, firstUpdated, updated, event
handlers or any controller method. Node has no window or document. Lit provides a small
stand-in with a global customElements registry, which Firelight's defineElement uses, and
little else.
The result is the component's HTML inside a <template shadowrootmode="open">, which is
declarative shadow DOM: the browser's HTML parser attaches it to the element as its shadow root,
styles included, without JavaScript. For the counter from dotnet new firelight:
<click-counter><template shadowroot="open" shadowrootmode="open"><style>
button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; }
</style><!--lit-part ShslJ/USeUY=-->
<!--lit-node 0--><button >
Clicked <!--lit-part-->0<!--/lit-part--> times
</button><!--/lit-part--></template></click-counter>
The comments mark the template and its holes, so Lit can find them again in the browser.
(shadowroot is the attribute's older name, for older browsers.)
Hydration
In the browser, Lit needs hydration support, from @lit-labs/ssr-client, to use those markers.
It changes LitElement so that a component that arrives with a shadow root doesn't render on its
first update. Instead, Lit matches the template render returns against the HTML that's there,
and connects each hole to its place in the DOM. Event listeners are attached then; until then, the
component is visible but doesn't respond.
This component shows which side rendered it. Turn JavaScript off in your browser and reload to see what the build wrote; with JavaScript on, the first two lines change once the component has hydrated, and the button works:
module Snippets.PrerenderProbe
open Browser
open Browser.Types
open Fable.Core
open Firelight
open type Firelight.Lit
/// Prerendered at build time, then hydrated: it shows which side rendered it.
[<AttachMembers>]
type PrerenderProbe() =
inherit LitElement()
let mutable stopListening = ignore
static member properties =
PropertyDeclarations.create [
"hydrated", PropertyDeclaration<bool>(state = true)
"width", PropertyDeclaration<float>(state = true)
"clicks", PropertyDeclaration<int>(state = true)
]
static member styles =
css
$$"""
:host { display: grid; gap: 0.75rem; justify-items: start; }
dl { display: grid; grid-template-columns: auto auto; gap: 0.25rem 1rem; margin: 0; }
dt { font-weight: 600; }
dd { margin: 0; }
button { font: inherit; padding: 0.3rem 0.8rem; }
"""
member val hydrated = false with get, set
member val width = 0.0 with get, set
member val clicks = 0 with get, set
// Browser APIs only here, never in the constructor, willUpdate or render.
override this.connectedCallback() =
base.connectedCallback ()
stopListening <- Ev.listen window "resize" (Ev.event (fun _ -> this.width <- window.innerWidth))
// The first update hydrates, so it must render what the server did. Afterwards, show what
// only the browser knows.
promise {
let! _ = this.updateComplete
this.hydrated <- true
this.width <- window.innerWidth
}
|> Promise.start
override this.disconnectedCallback() =
base.disconnectedCallback ()
stopListening ()
override this.render() =
let renderedBy =
if this.hydrated then "your browser, after hydration" else "Lit SSR, at build time"
let width =
if this.hydrated then $"{this.width} px" else "unknown: there's no window at build time"
html
$"""
<dl>
<dt>Rendered by</dt><dd>{renderedBy}</dd>
<dt>Window width</dt><dd>{width}</dd>
</dl>
<button @click={fun _ -> this.clicks <- this.clicks + 1}>Clicked {this.clicks} times</button>"""
defineElement<PrerenderProbe> "my-prerender-probe"
- Rendered by
- Lit SSR, at build time
- Window width
- unknown: there's no window at build time
Hydration trusts that the first render in the browser produces what the build did. The component
renders the same first: hydrated starts false on both sides. It reads window only in
connectedCallback (and cleans up as in Lifecycle),
and only changes what it shows after updateComplete, once the hydrating update has finished.
Changing it in firstUpdated would work too, but Lit's development build warns about an update
started there.
Rules for prerender-safe components
- Register with
defineElement. It uses the globalcustomElements, which exists in Node. - Use
window,documentand other browser APIs only inconnectedCallback,firstUpdated,updated, event handlers and controllers'hostConnectedand later methods. Module-level code, the constructor,willUpdateandrenderrun in Node. Where code that runs in both places must differ, test Lit'sisServer(fromopen type Firelight.Lit): it'struein Node andfalsein the browser. Keep the first render the same either way. - Render the same first in the browser as at build time. State that only the browser knows, such as the window's size or a saved preference, changes after the first update, as above.
- Do first-time work in
willUpdateundernot this.hasUpdated, not only whenchangedlists a property. In the browser, the first update lists every property that has a value; at build time, it leaves out a property that still has the value itsmember valgave it. - Have the data ready before rendering. Lit SSR doesn't wait for promises:
untilrenders its placeholder, and a task renders its initial state. Pass data in as properties at build time, and give the browser the same data for its first render. - Put
nothing, not""orNone, in a hole that starts empty and gets text later. Lit SSR writes no text for an empty value, so after hydration Lit has no text node to update, and writes the text into one of its comments instead, where it's never seen. - Bind boolean attributes such as
checked,disabledandhiddenwith?, not as properties. For.checked={false}, Lit SSR writeschecked="false", and any value means ticked. For a checkbox,?checkedonly sets the starting state: once someone has clicked the box, the attribute no longer changes it. If your code changes the box after that, also set itscheckedproperty inupdated, through aref. - Inherit
LitElement, notLightDomElement. Lit SSR always renders into a shadow root, and in the browser aLightDomElementrenders its children next to it, where the shadow root hides them: the prerendered copy stays on screen, and never changes.
For text that may be missing, it's simplest to leave out the whole element, so the empty case is
nothing:
open Firelight
open type Firelight.Lit
let errorLine (error: string option) =
match error with
| Some message -> html $"""<p class="error">{message}</p>"""
| None -> nothing
What happens when the first render differs depends on the difference. A hole whose text or attribute differs keeps what the build wrote, while Lit records the new value, so the page shows the old value until the next change. A different template in a hole throws "Hydration value mismatch: Unexpected TemplateResult rendered to part" in the browser's console, and the component stops there.
The double render
Hydration support must run before LitElement is defined. If Lit loads first, nothing fails and
nothing is logged: the component renders a second copy of itself into its prerendered shadow root,
and the page shows both.
Import order alone doesn't guarantee that once a bundler is involved. When this site had a
<script type="module"> for hydration support followed by one for each demo, Vite merged them into
one entry, and with several pages sharing Lit, it moved LitElement into a shared chunk that
loaded first. The rating demo showed ten stars instead of five.
Load hydration support with a static import, then the components with import(), which can't
start until the module that calls it has run:
<script type="module">
import "@lit-labs/ssr-client/lit-element-hydrate-support.js";
import("/build/App.js");
</script>
For the same reason, a script on a page with prerendered components must not import Lit, or a module that uses Lit, statically.
Opting a component out
Some components can't render the same at build time as in the browser. Leave them out of the
prerendered HTML: an element that isn't registered when Lit SSR runs is written out as it is, and
renders in the browser like any other. Three of this site's package pages do this, with
ssr=false on their demos:
- Router:
RouterControllerreadswindow.locationwhen it's created, and a page rendered at build time has no address, while the routing demo serves many from one page. - Virtualizer: which rows it renders depends on the size of the scrolling element, which only the browser can measure.
- Task:
LitTaskstarts its task inhostUpdate, which Lit SSR doesn't call, so the browser's first render shows the pending state where the build rendered the initial one.
On this site, leaving a demo's module out of one page isn't enough, because the build renders
every page in one Node process: once any page has registered an element, Lit SSR prerenders it
everywhere. So the renderer replaces an ssr=false demo's HTML with a placeholder comment before
rendering, and puts it back afterwards.
How this site does it
The site follows the same steps. A Vite plugin in vite.config.js gives Rollup one virtual
.html input per Markdown page, and renders each when Rollup loads it:
resolveId: (id) => (inputs.has(id) ? id : undefined),
load: (id) => (inputs.has(id) ? render(inputs.get(id)) : undefined),
render calls the renderer, an F# project in site/Renderer that
Fable compiles to JavaScript. It imports each demo's module, so the demo's
elements are registered, then renders the whole page with Lit SSR. In Prerender.fs:
// Registering a demo's custom elements is what makes Lit SSR prerender them.
for demo in body.Demos do
if demo.Prerender then
do! host.loadModule demo.Module
LitSsr.fs binds the two functions it needs from @lit-labs/ssr:
[<Import("render", "@lit-labs/ssr")>]
let private render (value: obj) : RenderResult = jsNative
[<Import("collectResult", "@lit-labs/ssr/lib/render-result.js")>]
let private collectResult (result: RenderResult) : JS.Promise<string> = jsNative
/// Renders a template, including the shadow roots of any registered custom elements, to a string.
let renderToString (template: TemplateResult) : JS.Promise<string> = collectResult (render template)
The page around the demos is a server-only template (the html from @lit-labs/ssr), so only
the demos carry hydration markers. Layout.fs then writes the module script shown under
The double render, with an import() for each demo on the page.
Prerender your own Vite project
Start from the project dotnet new firelight creates (see Get started), with its
<click-counter>. Install Lit SSR for the build, and hydration support for the browser:
npm install @lit-labs/ssr-client
npm install --save-dev @lit-labs/ssr
In index.html, replace the <script> that loads /build/App.js:
<script type="module">
// Hydration support first: it must run before Lit defines LitElement.
import "@lit-labs/ssr-client/lit-element-hydrate-support.js";
// Then the app. Fable compiles App.fs to build/App.js.
import("/build/App.js");
</script>
Add a plugin to vite.config.js that renders the component into the page when you build:
import { defineConfig } from "vite";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
// Renders <click-counter> with Lit SSR when building, so the page shows it before any
// JavaScript has loaded. index.html then hydrates it in the browser.
function prerender() {
return {
name: "prerender",
apply: "build",
async transformIndexHtml(page) {
const { render, html } = await import("@lit-labs/ssr");
const { collectResult } = await import("@lit-labs/ssr/lib/render-result.js");
// Importing the app registers its elements. Fable has compiled it before `vite build` runs.
await import(pathToFileURL(resolve(import.meta.dirname, "build/App.js")).href);
const counter = await collectResult(render(html`<click-counter></click-counter>`));
return page.replace("<click-counter></click-counter>", counter);
},
};
}
// https://vite.dev/config/
export default defineConfig({
// `npm run dev` starts Vite from `dotnet fable watch`, so keep Fable's compiler output on screen.
clearScreen: false,
plugins: [prerender()],
});
Run npm run build, then npm run preview. The built dist/index.html has the counter's
declarative shadow DOM, and with JavaScript off the page still shows "Clicked 0 times". npm run dev
doesn't prerender, and needs nothing else: hydration support leaves components without a
prerendered shadow root to render as usual.
The import uses a file URL built from import.meta.dirname, rather than import("./build/App.js"),
so that Vite, which bundles vite.config.js before running it, leaves the import to Node. To
prerender more elements, render each the same way, or render a larger piece of the page.
Common mistakes
Some mistakes stop the build or show an error in the browser:
| You wrote | What you see | Write instead |
|---|---|---|
window, document or localStorage at a module's top level, in a constructor, willUpdate or render |
The build fails: "ReferenceError: window is not defined" | Use them in connectedCallback or later |
A different template in the browser's first render, from an if on browser state |
The console: "Hydration value mismatch: Unexpected TemplateResult rendered to part" | Render the same first, and change after updateComplete |
Others fail silently:
| You wrote | What happens | Write instead |
|---|---|---|
| A static import of Lit or your components before hydration support | Each component renders a second copy of itself | Hydration support, then import() |
{message}, which is "" or None at first |
Text set later never appears: Lit writes it into a comment | nothing until there's text: {if message <> "" then html $"{message}" else nothing} |
.checked={false}, .disabled={false} or .hidden={false} |
Lit SSR writes checked="false", so the box is ticked until the component hydrates, and for good without JavaScript |
?checked={flag}, and see the rules above |
Browser state, such as window.innerWidth, in the first render |
The prerendered text stays, though Lit thinks it has changed | Show it after updateComplete |
A LightDomElement |
The prerendered copy stays on screen and the live one is hidden | LitElement, or leave it out |
if changed.ContainsKey "items" then ... in willUpdate |
Not run at build time while items has its member val value, so the prerendered HTML lacks what it computes |
if not this.hasUpdated || changed.ContainsKey "items" |