Skip to content
Forms, switch product

React SSR

<GuiForm> from @golemui/gui-react renders on the server with React’s renderToString and hydrates with hydrateRoot. This page documents the contract for any React server. The Next.js guide shows the App Router wiring. Requires GolemUI 1.5.0 or newer.

The server renders the React layer: the <form>, the layouts and every gui-* tag with its attributes. The Lit elements behind those tags render nothing on the server. Each one carries the defer-hydration attribute, so it stays empty in the browser until React has hydrated the tree. The @lit/react wrapper then removes the attribute and Lit renders the widget internals. Without the hold, an element that upgraded before hydration would add children the server markup does not have, and hydration would fail on them.

The form store initializes during render rather than in an effect, so a server render sees the initial data. An omitted formName is derived with useId, which returns the same id on the server and on the hydrating client.

Widgets load through dynamic imports, and the server cannot wait for them while it renders. Preload them before renderToString and before hydrateRoot. See Preload the widgets for the registry rules.

src/entry-server.tsx
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-react';
import { renderToString } from 'react-dom/server';
import { App } from './App';
export async function render(): Promise<string> {
await preloadFormWidgets({ widgetLoaders });
return renderToString(<App />);
}
src/entry-client.tsx
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-react';
import { hydrateRoot } from 'react-dom/client';
import { App } from './App';
preloadFormWidgets({ widgetLoaders }).then(() => {
hydrateRoot(document.getElementById('root')!, <App />, {
// A hydration mismatch is a recoverable error. Log it while you set things up.
onRecoverableError: (error) => console.error(error),
});
});

Link the stylesheet from the document rather than importing it in the client entry, so the page is styled with JavaScript disabled. The server replaces the placeholder with the rendered app.

<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
src/styles.css
@import '@golemui/gui-components/index.css';

Keep config at module scope. A new object per render re-initializes the form, and on the server that means a different form on every request.

src/App.tsx
import type { FormSubmitEvent } from '@golemui/core';
import { GuiForm } from '@golemui/gui-react';
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' }),
];
const config: GuiFormInitConfig = {
formName: 'harness-form', // same form id on the server and the client
formDef,
data: { firstName: 'Ada', lastName: 'Lovelace' },
};
const onFormSubmit = (event: FormSubmitEvent) => console.log(event.data);
export function App() {
return <GuiForm config={config} formSubmit={onFormSubmit} />;
}
src/App.tsx
import type { FormSubmitEvent } from '@golemui/core';
import { GuiForm } from '@golemui/gui-react';
import formDef from './signup.json';
const config = {
formName: 'harness-form', // same form id on the server and the client
formDef,
data: { firstName: 'Ada', lastName: 'Lovelace' },
};
const onFormSubmit = (event: FormSubmitEvent) => console.log(event.data);
export function App() {
return <GuiForm config={config} formSubmit={onFormSubmit} />;
}

One Vite config builds both bundles. vite build emits the client bundle plus an index.html that already links the hashed script and stylesheet, and vite build --ssr src/entry-server.tsx emits the server bundle.

Terminal window
vite build # client bundle and index.html
vite build --ssr src/entry-server.tsx # server bundle

A request handler reads the built index.html, calls render() and replaces the placeholder:

const page = template.replace('<!--app-html-->', await render());

In development, Vite’s middleware mode serves the same flow through vite.ssrLoadModule('/src/entry-server.tsx'). The complete server, with both modes, is apps/react-ssr-harness in the GolemUI repository.

The output of renderToString for the harness form, cut down to the form tag, the first text input and the submit button. Whitespace added; everything else is verbatim.

<form id="harness-form" novalidate="" dir="ltr">
<!-- layout wrappers omitted -->
<div class="gui-textinput gui-field">
<gui-textinput defer-hydration=""></gui-textinput>
</div>
<!-- the other fields look the same -->
<div class="gui-button gui-field">
<gui-button defer-hydration=""></gui-button>
</div>
</form>

The form, the layouts and the field wrappers are there. Every gui-* element is empty and held by defer-hydration; values, labels and inputs appear once React has hydrated and Lit renders the elements.

The gui-* elements can also render their content on the server, so labels, inputs and values are in the HTML. 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. Without it, the page stays as described above.

To turn it on, install @lit-labs/ssr and import the server entry point of GolemUI Components once, in server code only:

src/entry-server.tsx
import '@golemui/gui-components/ssr';

Each element then arrives with its content, marked with data-golemui-ssr and still held by defer-hydration. Once React has hydrated, the element replaces that content with its live render. What the elements render on the server, and its limits, are in Server rendering in the GolemUI Components docs.

  • Keep the form config at module scope and reuse the same object on every render. It holds functions, so it belongs in a client component when the host distinguishes them.
  • formName is optional. Set it when the form id in the markup must be predictable.
  • Handlers never run on the server. onLoad fires once the widget has mounted in the browser.
  • A widget that was not preloaded renders empty with no warning.
  • A gui-* tag written directly in your own JSX gets no defer-hydration. Render it after mount, or it upgrades before hydration and React reports a mismatch.
  • Log onRecoverableError during development. A hydration mismatch there usually means a config that changed identity between the server and the client, or a value that depends on the clock.

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.

A custom widget built as a Lit element and rendered inside a React form must be registered with safeDefine from @golemui/lit instead of customElements.define, so it honors the defer-hydration hold. See Custom widgets in Lit SSR.