Analog
GolemUI renders on the server in Analog with the same packages and the same <gui-form> component as a client-only Angular app. Verified with Analog 2 and Angular 21, zoneless. The contract the adapter follows is on the Angular SSR page; this page is the wiring. Requires GolemUI 1.5.0 or newer.
Quick start
Section titled “Quick start”npx degit golemui/golemui/templates/analog my-appcd my-appnpm installnpm startOpen http://localhost:5173 and view the page source: the form markup arrives without JavaScript. The same template runs in StackBlitz. Node.js 22.12 or newer.
Installation
Section titled “Installation”npm i @golemui/core @golemui/gui-shared @golemui/angular @golemui/gui-angular @golemui/gui-components --savevite.config.ts
Section titled “vite.config.ts”Analog derives the ngServerMode define from the top-level build.ssr option, which its platform plugin leaves unset. The server bundle then gets false, and Angular runs browser-only code such as afterNextRender during the server render. A small plugin sets the define for the ssr environment:
import analog from '@analogjs/platform';import { defineConfig, type Plugin } from 'vite';
// Analog derives `ngServerMode` from the top-level `build.ssr`, which is unset. So the server// bundle gets `false`, and Angular runs browser-only code such as `afterNextRender` on the server.function defineServerModeForSsrBuild(): Plugin { return { name: 'define-ng-server-mode-for-ssr-build', config: () => ({ environments: { ssr: { define: { ngServerMode: 'true' } } } }), };}
export default defineConfig({ build: { target: ['es2022'] }, plugins: [analog(), defineServerModeForSsrBuild()],});The starter carries a second plugin that closes the sass-embedded compiler after the build, because Analog resolves one config per environment and Vite never disposes the compiler the Angular plugin falls back to. Without it, vite build does not exit. Copy it from the starter when your components use .scss styles.
Preload before bootstrap
Section titled “Preload before bootstrap”The widgets have to be in the registry before the first render, on the server and on the client. The form then creates every widget synchronously, so the server markup is complete and hydration reuses the server DOM instead of re-rendering it.
Server
Section titled “Server”Analog calls the default export of main.server.ts for every page request and for each prerendered route. Preload once per server process and await it before delegating to Analog’s render.
import '@angular/platform-server/init';import { render } from '@analogjs/router/server';import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-angular';import { App } from './app/app';import { config } from './app/app.config.server';
const widgetsReady = preloadFormWidgets({ widgetLoaders });
const renderPage = render(App, config);
export default async function renderWithWidgets( ...args: Parameters<typeof renderPage>): ReturnType<typeof renderPage> { await widgetsReady; return renderPage(...args);}Client
Section titled “Client”import { bootstrapApplication } from '@angular/platform-browser';import { preloadFormWidgets } from '@golemui/core';import { widgetLoaders } from '@golemui/gui-angular';import { App } from './app/app';import { appConfig } from './app/app.config';
preloadFormWidgets({ widgetLoaders }).then(() => { bootstrapApplication(App, appConfig).catch((err) => console.error(err));});Providers
Section titled “Providers”Hydration needs the same providers on both sides. provideClientHydration() lives in the shared config; the server config adds provideServerRendering().
import { provideFileRouter } from '@analogjs/router';import { type ApplicationConfig, provideZonelessChangeDetection,} from '@angular/core';import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), provideFileRouter(), provideClientHydration(), ],};import { type ApplicationConfig, mergeApplicationConfig } from '@angular/core';import { provideServerRendering } from '@angular/platform-server';import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = { providers: [provideServerRendering()],};
export const config = mergeApplicationConfig(appConfig, serverConfig);Styles
Section titled “Styles”Link the stylesheet from index.html rather than importing it in main.ts, so the server-rendered page is styled before any JavaScript runs. GolemUI never injects it. data-theme="auto" follows the OS color scheme.
<html lang="en" data-theme="auto"> <head> <link rel="stylesheet" href="/src/styles.css" /> </head> <body> <app-root></app-root> <script type="module" src="/src/main.ts"></script> </body></html>@import '@golemui/gui-components/index.css';Render the form
Section titled “Render the form”A page component renders <gui-form> like any Angular component. Set formName: with server-side rendering the server and the browser must agree on the form id, or the id in the server markup is not predictable.
import { Component } from '@angular/core';import type { FormEvent } from '@golemui/core';import { FormComponent } from '@golemui/gui-angular';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' }),];
@Component({ selector: 'app-home', imports: [FormComponent], template: `<gui-form [config]="config" (formEvent)="onFormEvent($event)"></gui-form>`,})export default class HomePage { protected config: GuiFormInitConfig = { formName: 'signup', // required: same form id on the server and the client formDef, };
protected onFormEvent(event: FormEvent) { if (event.name === 'handleSubmit') console.log('Submitted:', event.data); }}import { Component } from '@angular/core';import type { FormEvent } from '@golemui/core';import { FormComponent } from '@golemui/gui-angular';import formDef from './signup.json';
@Component({ selector: 'app-home', imports: [FormComponent], template: `<gui-form [config]="config" (formEvent)="onFormEvent($event)"></gui-form>`,})export default class HomePage { protected config = { formName: 'signup', // required: same form id on the server and the client formDef, };
protected onFormEvent(event: FormEvent) { if (event.name === 'handleSubmit') console.log('Submitted:', event.data); }}Render the widgets on the server
Section titled “Render the widgets on the server”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 Angular SSR page.
To turn it on, install @lit-labs/ssr and render the elements just before Angular serializes the page, with a provider in the server config:
import { type ApplicationConfig, DOCUMENT, inject, mergeApplicationConfig,} from '@angular/core';import { BEFORE_APP_SERIALIZED, provideServerRendering,} from '@angular/platform-server';import { renderElementsInDocument } from '@golemui/gui-components/ssr';import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = { providers: [ provideServerRendering(), { provide: BEFORE_APP_SERIALIZED, multi: true, useFactory: () => { const document = inject(DOCUMENT); return () => renderElementsInDocument(document); }, }, ],};
export const config = mergeApplicationConfig(appConfig, serverConfig);What the elements render on the server, and its limits, are in Server rendering in the GolemUI Components docs.
- Set
formName. 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. - Widget handlers,
formEventandformSubmitnever run on the server.onLoadfires once the widget has mounted in the browser. formHealthis called once during the server render with the initial health, because the component subscribes inngOnInit. Keep the handler free of DOM access.CUSTOM_ELEMENTS_SCHEMAis only needed when your own template places a rawgui-*tag. Such a tag gets nodefer-hydration, so bind[attr.defer-hydration]yourself:''on the server andnullin the browser. See the Angular SSR rules.- Keep the app zoneless. Analog evaluates
main.server.tsinside the Vite dev server process, wherezone.js/nodereplaces the globalPromise.sass-embeddeddetects async importer results withinstanceof Promise, so with zone.js loaded every stylesheet request fails.
Custom widgets
Section titled “Custom widgets”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.
See also
Section titled “See also”- Angular SSR: how the adapter renders and hydrates, what the server sends, and the setup without Analog.
- Server-Side Rendering Overview: the rules shared by every framework.
- Angular Integration: the full
<gui-form>inputs reference.