Guides

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:

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"

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

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:

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"