Skip to content
Forms, switch product

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.

  • GolemUI 1.5.0 or newer.
  • Node.js 22.12 or newer on the server. CI runs on Node 24. Other runtimes with standard crypto and Intl are 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.

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.

FrameworkAdapter packageFramework GuideMeta-framework Guide
React@golemui/gui-reactReact SSRNext.js
Angular@golemui/gui-angularAngular SSRAnalog
Lit@golemui/gui-litLit SSRAstro
Vue@golemui/gui-vueVue SSRNuxt

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.

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 });

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.

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 config
export const customWidgetLoaders = {
rating: async () => (await import('./rating')).Rating,
};
// preload, on the server and on the client
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-react';
import { customWidgetLoaders } from './widgets';
await preloadFormWidgets({
widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },
});
// every form config
const config = { formName: 'signup', formDef, customWidgetLoaders };

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.

FrameworkServer outputClient takeoverdefer-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
LitThe 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.

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.

FrameworkSetup
ReactImport @golemui/gui-components/ssr on the server
Next.jsImport it in the root layout
VuePass the HTML through renderElementsInHtml
NuxtAdd the @golemui/gui-components/nuxt module
AngularAdd a BEFORE_APP_SERIALIZED provider
AnalogAdd the same provider

The same setup renders the gui-* elements outside a form. See Server rendering in the GolemUI Components docs.

The form id in the markup derives from formName. The server and the client must produce the same id.

FrameworkformNameWhy
ReactOptional, recommendedAn 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.
AngularRequiredWithout 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.
LitMandatoryrenderForm throws without it, and resumeServerRenderedForm expects the name the server used.
VueOptional on Vue 3.5 and newer, recommendedThe adapter derives a stable id on both sides. Set a name when the id must be predictable, and always on older Vue versions.

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.

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. 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 data when 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.
  • No streaming. The server renders the form to a string and the client hydrates or resumes it. Streaming renderers such as React’s renderToPipeableStream are not supported.
  • No transfer state. Nothing is serialized into the HTML. The client rebuilds the form state from the same formDef, data and meta it 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 uid in 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.