Server-Side Rendering Overview
GolemUI forms render on the server. The form markup arrives in the HTML, styled before any JavaScript runs, and the browser takes over without re-rendering what the server already sent. The React, Angular, Lit and Vue adapters support it, on your own Node server or through a meta-framework.
Requirements
Section titled “Requirements”- GolemUI 1.5.0 or newer.
- Node.js 22.12 or newer on the server. CI runs on Node 24. Other runtimes with standard
cryptoandIntlare expected to work but are not tested. - For Lit hosts, and to render the widgets on the server in the other frameworks, the optional peer dependency
@lit-labs/ssr, version 4.1.0 or newer, installed in the project that renders on the server. - The starter templates need the Node version of their meta-framework: Next.js 18.18, Nuxt 22.12, Analog 22.12, Astro 22.12.
Pick your host
Section titled “Pick your host”Each framework guide documents the contract of its adapter: what to preload, what the server emits, and the rules that keep both sides in sync. Each meta-framework guide shows the wiring for the host the adapter was verified with.
| Framework | Adapter package | Framework Guide | Meta-framework Guide |
|---|---|---|---|
| React | @golemui/gui-react | React SSR | Next.js |
| Angular | @golemui/gui-angular | Angular SSR | Analog |
| Lit | @golemui/gui-lit | Lit SSR | Astro |
| Vue | @golemui/gui-vue | Vue SSR | Nuxt |
A host that runs several frameworks follows the matching framework guide. Astro with a React or Vue island, for example, follows the React SSR or Vue SSR contract.
To start from a running app instead, grab one of the server-side rendering starters and come back here for the rules.
Preload the widgets
Section titled “Preload the widgets”Widgets load through dynamic import(), which keeps them off the critical path in the browser. A server render cannot wait for a dynamic import while it produces the page, so the widgets must be in the registry before the first render. That is what preloadFormWidgets does. Call it before the first render on the server and on the client: without the client call, the first client render resolves no widgets and hydrates an empty tree over markup that is not empty.
import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-react';
await preloadFormWidgets({ widgetLoaders });import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-angular';
await preloadFormWidgets({ widgetLoaders });import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-lit';
await preloadFormWidgets({ widgetLoaders });import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-vue';
await preloadFormWidgets({ widgetLoaders });// A page with no framework uses the Lit widget set.import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-lit';
await preloadFormWidgets({ widgetLoaders });The registry caches preloaded components per process, keyed by the loader function. One preload warms every later render in that process. Sharing the cache across requests is safe: its entries are widget modules, not form state.
A loader that fails rejects the preload promise and stores nothing, so the next call retries it. Handle the rejection, or the page stays on the server markup with no error.
A widget that was not preloaded renders empty on the server. Vue and Angular log a warning, React and Lit do not:
[GolemUI] Widget "markdown" was not preloaded; its server markup will be empty. Call preloadFormWidgets() before rendering.Custom widget loaders
Section titled “Custom widget loaders”Keep custom widget loaders in one module-scope object. Spread it into the preload call and pass the same object as customWidgetLoaders in every form config. The cache is keyed by loader function identity, so an object literal recreated per component or per request misses the cache, and the widget renders empty on the server.
// widgets.ts: one object, imported by the preload and by every form configexport const customWidgetLoaders = { rating: async () => (await import('./rating')).Rating,};// preload, on the server and on the clientimport { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-react';import { customWidgetLoaders } from './widgets';
await preloadFormWidgets({ widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },});// preload, on the server and on the clientimport { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-angular';import { customWidgetLoaders } from './widgets';
await preloadFormWidgets({ widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },});// preload, on the server and on the clientimport { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-lit';import { customWidgetLoaders } from './widgets';
await preloadFormWidgets({ widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },});// preload, on the server and on the clientimport { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-vue';import { customWidgetLoaders } from './widgets';
await preloadFormWidgets({ widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },});// preload, on the server and on the clientimport { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-lit';import { customWidgetLoaders } from './widgets';
await preloadFormWidgets({ widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },});// every form configconst config = { formName: 'signup', formDef, customWidgetLoaders };What the server sends
Section titled “What the server sends”The four adapters serialize to different depths, on purpose. The GolemUI widgets are Lit custom elements that render into light DOM. By default, React, Angular and Vue stop at the custom element boundary and let the browser fill in the widget internals once it upgrades the elements. Lit renders the elements themselves on the server, so the internals are in the HTML.
| Framework | Server output | Client takeover | defer-hydration |
|---|---|---|---|
| React | <form>, layouts and every gui-* tag with its attributes. Widget internals are empty. | React hydrates. The @lit/react wrapper removes the attribute on mount, then Lit renders the internals. | Yes |
| Angular | <form>, layouts, the native Angular widget internals (flex, grid, tabs) and every gui-* tag. Custom element internals are empty. | Angular hydrates in place. The first change detection pass removes the attribute, then Lit renders the internals. | Yes |
| Lit | The whole form: inputs, labels and values. | resumeServerRenderedForm replaces the server markup with one live render before the next paint. Nothing is hydrated. | Yes |
| Vue | <form>, layouts and every gui-* tag with primitive props reflected as attributes (value="Ada", label="First name"). Widget internals are empty. | Vue hydrates, and the elements upgrade in place. | No |
defer-hydration is a community protocol for custom elements: an element that connects with the attribute renders nothing until the attribute is removed. GolemUI elements render into light DOM, so without the hold an element that upgrades between the server render and the framework’s hydration pass fills itself with children the server markup does not have, and hydration fails on the extra nodes. Nothing sets the attribute in a client-only app, where the protocol changes nothing.
Render the widgets on the server
Section titled “Render the widgets on the server”React, Angular and Vue can also render the widget internals on the server, as Lit does, with one line of setup. It is optional. The fields are complete in the HTML before any JavaScript runs, and the page doesn’t change when the elements upgrade; in exchange, the HTML is bigger and the server does more work on each request. Each element still replaces its server content with its live render when it upgrades.
The same setup renders the gui-* elements outside a form. See Server rendering in the GolemUI Components docs.
formName
Section titled “formName”The form id in the markup derives from formName. The server and the client must produce the same id.
| Framework | formName | Why |
|---|---|---|
| React | Optional, recommended | An omitted name is derived with useId, which matches on the server and the hydrating client. A fixed name keeps the id predictable in the markup. |
| Angular | Required | Without it the server and the client mint different random ids. Hydration still completes and the client id wins, but the id in the server markup is not predictable. |
| Lit | Mandatory | renderForm throws without it, and resumeServerRenderedForm expects the name the server used. |
| Vue | Optional on Vue 3.5 and newer, recommended | The adapter derives a stable id on both sides. Set a name when the id must be predictable, and always on older Vue versions. |
Handlers run in the browser
Section titled “Handlers run in the browser”onLoad, onChange, onClick and every other handler in the form definition, as well as the formEvent and formSubmit callbacks, run in the browser only. A server render never calls them. onLoad fires once the widget has mounted on the client.
formHealth is the exception. The Vue and Angular adapters subscribe to the form health while the component initializes, and that runs during the server render, so a formHealth handler in those two frameworks is called once on the server with the initial health. Keep it free of DOM access. React calls it in the browser only. Lit dispatches the event on the server, but no listener can be attached there.
Validation runs on its triggers and error display waits for a touched field, so the server markup of an untouched form never contains error messages.
Styles
Section titled “Styles”GolemUI never injects its stylesheet. Load @golemui/gui-components/index.css once at document level, so the server-rendered page is styled before any JavaScript runs: the root layout in Next.js, css in nuxt.config.ts, the page frontmatter in Astro, a <link> in index.html for Angular. Set data-theme="auto" on <html> to follow the OS color scheme. See Theming.
Dependencies, i18n and widgets to watch
Section titled “Dependencies, i18n and widgets to watch”- Dependencies. Anything passed as
dependencies(a markdown parser, for example) runs on the server too. It must work in Node and produce the same output on both sides, or hydration reports a mismatch. See Dependencies. - i18n. i18next is a module-level singleton. On the server, a shared instance is re-initialized by every request. An app that serves several languages should scope the translator per request instead. See i18n.
- Date and time widgets. Date pickers, time pickers and calendars read the clock. Give them an initial value in
datawhen the server and the client must agree on what they show. - Markdown widgets. They need a parser passed through
dependencies, and that parser runs in Node during the server render.
Limits
Section titled “Limits”- No streaming. The server renders the form to a string and the client hydrates or resumes it. Streaming renderers such as React’s
renderToPipeableStreamare not supported. - No transfer state. Nothing is serialized into the HTML. The client rebuilds the form state from the same
formDef,dataandmetait was given, which is deterministic and cannot differ from the server. - Reused function widgets. A function widget gets a positional uid on its first decode and keeps it. The same function object reused in a second form can collide with that form’s own function widget at the same position. Give the function widget an explicit
uidin that case. - Calendars and the server clock. On the Lit path calendar widgets read the server clock, so their markup can differ from the client’s across a midnight or timezone boundary. See Lit SSR rules.