Skip to content
Forms, switch product

Next.js

GolemUI renders on the server in the Next.js App Router with the same packages and the same <GuiForm> component as a client-only React app. Verified with Next.js 15 and React 19. The contract the adapter follows is on the React SSR page; this page is the wiring. Requires GolemUI 1.5.0 or newer.

Terminal window
npx degit golemui/golemui/templates/nextjs my-app
cd my-app
npm install
npm start

Open http://localhost:3000 and view the page source: the form markup arrives without JavaScript. The same template runs in StackBlitz. Node.js 18.18 or newer.

Terminal window
npm i @golemui/core @golemui/gui-shared @golemui/react @golemui/gui-react @golemui/gui-components --save

next.config.ts needs nothing GolemUI-specific.

The widgets have to be in the registry before the first render, on the server and on the client. A server render cannot wait for a widget’s dynamic import while it produces the page, and the client would otherwise hydrate an empty tree over markup that is not empty.

In Next.js that is a client component that suspends on a module-scope promise. The promise lives at module scope, so there is one per environment, and use() suspends the first render until it resolves: the server waits before emitting HTML, the client waits before hydrating.

src/app/golemui-provider.tsx
'use client';
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-react';
import { use, type ReactNode } from 'react';
const preloadPromise = preloadFormWidgets({ widgetLoaders });
export function GolemuiProvider({ children }: { children: ReactNode }) {
use(preloadPromise);
return <>{children}</>;
}

Wrap the app in the provider and load the stylesheet once. GolemUI never injects it. data-theme="auto" follows the OS color scheme.

src/app/layout.tsx
import type { ReactNode } from 'react';
import { GolemuiProvider } from './golemui-provider';
import '@golemui/gui-components/index.css';
import './styles.css';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" data-theme="auto">
<body>
<GolemuiProvider>{children}</GolemuiProvider>
</body>
</html>
);
}

A page that renders <GuiForm> is a client component: the config holds functions. Keep the config at module scope, because a new identity per render re-initializes the form.

src/app/page.tsx
'use client';
import type { FormEvent } from '@golemui/core';
import { GuiForm } from '@golemui/gui-react';
import { gui, type GuiFormInitConfig } from '@golemui/gui-shared';
const formDef = [
gui.inputs.dropdown('country', {
label: 'Country',
items: ['United States', 'Japan', 'Brazil', 'France'],
}),
gui.inputs.checkbox('pets', {
label: 'Travelling with pets?',
include: { when: '!!$form.country' },
}),
gui.actions.button({ label: 'Submit', uid: 'submit', onClick: () => 'handleSubmit' }),
];
const config: GuiFormInitConfig = {
formName: 'signup', // keeps the form id predictable on both sides
formDef,
};
function handleFormEvent(event: FormEvent) {
if (event.name === 'handleSubmit') console.log('Submitted:', event.data);
}
export default function Page() {
return <GuiForm config={config} formEvent={handleFormEvent} />;
}
src/app/page.tsx
'use client';
import type { FormEvent } from '@golemui/core';
import { GuiForm } from '@golemui/gui-react';
import formDef from './signup.json';
const config = {
formName: 'signup', // keeps the form id predictable on both sides
formDef,
};
function handleFormEvent(event: FormEvent) {
if (event.name === 'handleSubmit') console.log('Submitted:', event.data);
}
export default function Page() {
return <GuiForm config={config} formEvent={handleFormEvent} />;
}

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 on the React SSR page.

To turn it on, install @lit-labs/ssr and import the server entry point of GolemUI Components in the root layout, which is a Server Component:

src/app/layout.tsx
import '@golemui/gui-components/ssr';

Import it in the root layout rather than instrumentation.ts, which does not run when the build prerenders static pages. What the elements render on the server, and its limits, are in Server rendering in the GolemUI Components docs.

  • Pages and components that render <GuiForm> are client components ('use client').
  • Keep config at module scope.
  • formName is optional on React, since useId matches on both sides. 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.
  • Segment config such as export const dynamic = 'force-dynamic' only works in a server component file. Put it in a thin server page that renders the 'use client' implementation.
  • A raw gui-* tag placed directly in JSX takes object props as properties in React 19 (<gui-select options={list} onChange={…} />). It gets no defer-hydration, so render it after mount, or it upgrades before hydration and React reports a mismatch.

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

src/app/golemui-provider.tsx
import { customWidgetLoaders } from '../widgets';
const preloadPromise = preloadFormWidgets({
widgetLoaders: { ...widgetLoaders, ...customWidgetLoaders },
});