Skip to content
Forms, switch product

Lit SSR

Lit is the host that renders the most. @golemui/lit/ssr renders the whole form on the server through @lit-labs/ssr, widget internals included: inputs, labels and values are in the HTML, with no framework in between. It is also the path for a page with no framework at all. This page documents the contract for any Node server. The Astro guide shows the page-level wiring. Requires GolemUI 1.5.0 or newer.

renderTemplate takes any Lit html template that contains GolemUI elements and renders it in plain Node. Every element registered with safeDefine runs its connectedCallback on the server, so the widgets attach to the form context, read the initial data and render their internals.

The elements render into light DOM. @lit-labs/ssr wraps each element’s output in a declarative shadow root anyway, and renderTemplate removes the wrappers of the elements that render into their light DOM, so the output is the same light-DOM tree the client renders and global stylesheets apply to it. An element with a shadow root of its own, such as a third-party one, keeps it. Every GolemUI element in the output carries the defer-hydration attribute and stays inert in the browser, and the elements whose content the server rendered also carry data-golemui-ssr.

The client does not hydrate that markup. resumeServerRenderedForm clears the server-rendered children, assigns config and validators, and removes the attribute, which runs the held render. Lit renders before the browser paints again, so the cleared state is never visible.

Terminal window
npm i @golemui/core @golemui/gui-shared @golemui/lit @golemui/gui-lit @golemui/gui-components @golemui/gui-validators --save
npm i @lit-labs/ssr@^4.1.0 --save

@lit-labs/ssr is an optional peer dependency of @golemui/lit, version 4.1.0 or newer. Install it in the project that renders on the server.

Preload the widgets, then render. The preload has to finish first because the render is synchronous once it starts. Importing @golemui/gui-lit also registers <gui-form>.

src/entry-server.ts
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-lit';
import { renderTemplate } from '@golemui/lit/ssr';
import { html } from 'lit';
import { config } from './form-config';
export async function render(): Promise<string> {
await preloadFormWidgets({ widgetLoaders });
return renderTemplate(html`
<main>
<h1>Create account</h1>
<gui-form .config=${config}></gui-form>
</main>
`);
}

The template can hold the whole page. Everything in it renders in the same pass, GolemUI elements or not.

Preload the widgets again, so the first client render resolves every widget synchronously. Attach listeners and set properties before the resume, because the resume triggers the render and the events of that first render would otherwise be missed.

src/entry-client.ts
import { preloadFormWidgets, type FormSubmitEvent } from '@golemui/core';
import { widgetLoaders, type FormElement } from '@golemui/gui-lit';
import { initValidators } from '@golemui/gui-validators';
import {
resumeServerRenderedForm,
type FormElement as CoreFormElement,
} from '@golemui/lit';
import { config } from './form-config';
preloadFormWidgets({ widgetLoaders }).then(() => {
const form = document.querySelector<FormElement>('gui-form')!;
form.addEventListener('formSubmit', (event) => {
console.log((event as CustomEvent<FormSubmitEvent>).detail.data);
});
resumeServerRenderedForm(form as unknown as CoreFormElement, {
config: config as never,
validators: initValidators(),
});
});

resumeServerRenderedForm is typed for the core gui-core-form element. <gui-form> from the widget set follows the same contract with the widget set config, hence the casts, and it builds its own validators, so the value passed here is ignored.

Keep the config in a module both entries import, so both sides build the form from the same definition. Nothing in it may touch the DOM, and formName is mandatory.

src/form-config.ts
import { gui, type GuiFormInitConfig } from '@golemui/gui-shared';
const formDef = [
gui.inputs.textInput('firstName', { label: 'First name', validator: { required: true } }),
gui.inputs.textInput('lastName', { label: 'Last name', validator: { required: true } }),
gui.actions.button({ label: 'Create account', actionType: 'submit' }),
];
export const config: GuiFormInitConfig = {
formName: 'harness-form', // mandatory: same form id on the server and the client
formDef,
data: { firstName: 'Ada', lastName: 'Lovelace' },
};
src/form-config.ts
import formDef from './signup.json';
export const config = {
formName: 'harness-form', // mandatory: same form id on the server and the client
formDef,
data: { firstName: 'Ada', lastName: 'Lovelace' },
};

No framework plugin is needed: the widgets are custom elements, so Vite compiles plain TypeScript. vite build emits the client bundle plus an index.html that links the hashed script and stylesheet, and vite build --ssr src/entry-server.ts emits the server bundle. The request handler reads the built index.html and replaces a placeholder with await render():

<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>

Link @golemui/gui-components/index.css from the document, so the page is styled with JavaScript disabled. The complete server, with a development mode on Vite’s middleware, is apps/lit-ssr-harness in the GolemUI repository.

@golemui/lit/ssr exports:

ExportWhat it does
renderTemplate(template, { keepMarkers? })Renders a Lit html template containing GolemUI elements to a string. Removes the shadow root wrappers of the elements that render into their light DOM, the false-valued boolean attributes (selected="false", checked="false") that HTML would read as true, and, unless keepMarkers is set, the Lit hydration marker comments.
renderForm({ config, validators, autocomplete?, keepMarkers? })Renders one core <gui-core-form> element from a core FormInitConfig. For a custom widget set built on the core element. Throws without formName. For the default <gui-form>, use renderTemplate.
installLitSsrSupport()Prepares the Node environment. Called by the two render functions. Call it directly only when you call @lit-labs/ssr’s render yourself. It adds a classList accessor to the shim element prototype shared by every Lit element in the process, and querySelector, querySelectorAll and getRootNode on the safeDefine classes that lack them.
RegisteredElementRendererThe @lit-labs/ssr element renderer for the elements registered with safeDefine. Pass it before LitElementRenderer when you call render yourself.
cleanServerMarkup(markup, { keepMarkers? })Applies the clean-up of renderTemplate to markup you rendered yourself. Keeps the aria-*, data-*, draggable, spellcheck and contenteditable attributes, where "false" is a real value, and leaves the rest of the markup unchanged byte for byte.

GolemUI 2.0.0 renamed this API. renderGuiHtml is now renderTemplate, renderGuiFormHtml is renderForm and GuiSsrElementRenderer is RegisteredElementRenderer. cleanServerMarkup replaces stripShadowRootTemplates and stripFalseBooleanAttributes.

And @golemui/lit exports the client side:

ExportWhat it does
resumeServerRenderedForm(element, { config, validators })Replaces the server-rendered children with a live render and removes the defer-hydration hold. Call it after the preload.
safeDefine(tag, elementClass)Registers a custom element so it renders on the server and honors defer-hydration on the client. Use it for your own widgets.

The output of renderTemplate for the harness form, cut down to the form tags, the first text input and the submit button, with the layout elements and secondary attributes removed. Each field is a form widget element holding the component that renders the field.

<gui-form data-golemui-ssr defer-hydration>
<gui-core-form class="gui-form" data-golemui-ssr defer-hydration>
<form id="harness-form" novalidate dir="ltr">
<!-- layout elements omitted -->
<gui-textinput-input
class="gui-textinput gui-field"
data-golemui-ssr
defer-hydration
>
<gui-textinput
required
class="gui-field"
data-golemui-ssr
defer-hydration
>
<label class="gui-label" for="firstName" id="firstName_label">
<span class="gui-label__text">
First name<span aria-hidden="true"> *</span>
</span>
</label>
<div class="gui-widget">
<input type="text" id="firstName" value="Ada" required />
</div>
<div
class="gui-validator gui-validator--empty"
id="firstName_errors"
role="alert"
></div>
</gui-textinput>
</gui-textinput-input>
<!-- the other fields look the same -->
<gui-button-interactive
class="gui-button gui-field"
data-golemui-ssr
defer-hydration
>
<gui-button class="gui-field" data-golemui-ssr defer-hydration>
<div class="gui-widget">
<button type="submit" id="event_0">
<span>Create account</span>
</button>
</div>
</gui-button>
</gui-button-interactive>
</form>
</gui-core-form>
</gui-form>

Labels, inputs, values, the required flags and the empty error lists are all in the HTML. The page is readable and styled before any JavaScript runs. Every element carries defer-hydration until the resume, and data-golemui-ssr marks the content the server rendered, which the element replaces with its live render.

  • formName is mandatory. renderForm throws without it, and the resume expects the name the server used.
  • Import @golemui/lit/ssr from server code only.
  • Listeners and properties (formHealthBoundary, autocomplete) go on the element before resumeServerRenderedForm.
  • Handlers never run on the server. onLoad fires once the widget has rendered in the browser.
  • A widget that was not preloaded renders empty with no warning.
  • Calendar widgets read the server clock for the today marker and the focus fallback date, so their markup depends on when the server rendered and can differ from the client across a midnight or timezone boundary.

Register your own Lit widgets with safeDefine from @golemui/lit instead of @customElement or customElements.define. Only elements registered through safeDefine run connectedCallback on the server and honor the defer-hydration hold on the client.

import { safeDefine } from '@golemui/lit';
import { LitElement, html } from 'lit';
export class RatingElement extends LitElement {
// ...
}
safeDefine('my-rating', RatingElement);

Keep the loaders in one module-scope object, spread it into both preload calls and pass the same object as customWidgetLoaders in every form config. See Custom widget loaders.