Skip to content
Forms, switch product

Angular SSR

<gui-form> from @golemui/gui-angular renders through the standard @angular/platform-server pipeline and hydrates with provideClientHydration(). There is no GolemUI-specific server entry. This page documents the contract for any Angular server: Angular CLI projects with @angular/ssr, or a custom renderApplication call. The Analog guide shows the file-based setup that was verified end to end. Requires GolemUI 1.5.0 or newer.

The server renders the form structure and the native Angular widget internals, such as flex, grid and tabs layouts. The gui-* custom elements render nothing on the server. Each one carries the defer-hydration attribute, bound from isPlatformServer, so it stays empty in the browser until the first client change detection pass binds null and removes the attribute. Lit then renders the widget internals. Hydration reuses the server DOM instead of re-rendering it, as long as the widgets are preloaded before bootstrap so Angular creates every widget synchronously on both sides.

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

src/entry-server.ts
import {
bootstrapApplication,
provideClientHydration,
} from '@angular/platform-browser';
import type { BootstrapContext } from '@angular/platform-browser';
import {
provideServerRendering,
renderApplication,
} from '@angular/platform-server';
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-angular';
import { AppComponent } from './app.component';
/** Renders the page. `template` is the index.html contents; the result is the whole document. */
export async function render(template: string): Promise<string> {
await preloadFormWidgets({ widgetLoaders });
return renderApplication(
(context: BootstrapContext) =>
bootstrapApplication(
AppComponent,
{ providers: [provideServerRendering(), provideClientHydration()] },
context,
),
{ document: template },
);
}

provideClientHydration() is needed on the server too: it emits the annotations the client hydration pass reads. Building the providers inside the bootstrap callback keeps provideServerRendering() scoped to that render.

src/entry-client.ts
import {
bootstrapApplication,
provideClientHydration,
} from '@angular/platform-browser';
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-angular';
import { AppComponent } from './app.component';
preloadFormWidgets({ widgetLoaders }).then(() => {
bootstrapApplication(AppComponent, {
providers: [provideClientHydration()],
}).catch((error) => console.error(error));
});

renderApplication takes the page as the document and returns the whole rendered page, so there is no placeholder. Link the stylesheet from the document rather than importing it in the client entry, so the page is styled with JavaScript disabled.

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

With @angular/ssr (ng add @angular/ssr), the CLI owns the server and the two entries. The same three rules apply: await the preload before bootstrapApplication in main.ts, await it inside the bootstrap function that main.server.ts exports, and share provideClientHydration() between app.config.ts and app.config.server.ts, which adds provideServerRendering().

src/main.server.ts
import {
bootstrapApplication,
type BootstrapContext,
} from '@angular/platform-browser';
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-angular';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = async (context: BootstrapContext) => {
await preloadFormWidgets({ widgetLoaders });
return bootstrapApplication(App, config, context);
};
export default bootstrap;

Keep the form config in a module both entries import, and set formName.

src/form-config.ts
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' }),
];
export const config: GuiFormInitConfig = {
formName: 'harness-form', // required: same form id on the server and the client
formDef,
data: { firstName: 'Ada', lastName: 'Lovelace' },
};
src/form-config.ts
import formDef from './signup.json';
export const config = {
formName: 'harness-form', // required: same form id on the server and the client
formDef,
data: { firstName: 'Ada', lastName: 'Lovelace' },
};
src/app.component.ts
import { Component } from '@angular/core';
import type { FormSubmitEvent } from '@golemui/core';
import { FormComponent } from '@golemui/gui-angular';
import { config } from './form-config';
@Component({
selector: 'app-root',
imports: [FormComponent],
template: `<gui-form [config]="config" (formSubmit)="onSubmit($event)" />`,
})
export class AppComponent {
protected config = config;
protected onSubmit(event: FormSubmitEvent) {
console.log(event.data);
}
}

The GolemUI harness bundles the app with Vite and @analogjs/vite-plugin-angular. Two settings matter:

// vite.config.mts
import angular from '@analogjs/vite-plugin-angular';
import { defineConfig } from 'vite';
export default defineConfig(({ isSsrBuild }) => ({
plugins: [angular()],
ssr: {
// The published @angular packages are partially compiled and need the Angular
// linker, which runs inside the bundler. An externalized import skips it.
noExternal: [/^@angular\//],
},
build: { outDir: isSsrBuild ? 'dist/server' : 'dist/client' },
}));

vite build emits the client bundle plus an index.html that links the hashed script and stylesheet; vite build --ssr src/entry-server.ts emits the server bundle. The request handler reads the built index.html and returns await render(template). The complete server, with a development mode on Vite’s middleware, is apps/angular-ssr-harness in the GolemUI repository.

The output of renderApplication for the harness form, cut down to the form tag, the first text input and the submit button. Whitespace added and Angular’s hydration annotations removed; everything else is verbatim.

<form novalidate="" id="harness-form" dir="ltr">
<!-- layout components omitted -->
<gui-textinput-control class="gui-textinput gui-field" id="host-firstName">
<gui-textinput defer-hydration=""></gui-textinput>
</gui-textinput-control>
<!-- the other fields look the same -->
<gui-button-interactive class="gui-button gui-field" id="host-event_0">
<gui-button defer-hydration=""></gui-button>
</gui-button-interactive>
</form>

The Angular components (gui-textinput-control, the layouts) render with their structure. Every gui-* custom element is empty and held by defer-hydration; values, labels and inputs appear once Angular 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 render the elements just before Angular serializes the page, with a provider in the server config:

src/app/app.config.server.ts
import { DOCUMENT, inject } from '@angular/core';
import { BEFORE_APP_SERIALIZED } from '@angular/platform-server';
import { renderElementsInDocument } from '@golemui/gui-components/ssr';
const provideGolemuiServerRendering = () => ({
provide: BEFORE_APP_SERIALIZED,
multi: true,
useFactory: () => {
const document = inject(DOCUMENT);
return () => renderElementsInDocument(document);
},
});

Add provideGolemuiServerRendering() to the providers next to provideServerRendering(). The elements render from their attributes and from the values Angular bound as properties, and keep the defer-hydration hold: after the first change detection pass, each one 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.

  • 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, formEvent and formSubmit never run on the server. onLoad fires once the widget has mounted in the browser.
  • formHealth is called once during the server render with the initial health, because the component subscribes in ngOnInit. Keep the handler free of DOM access.
  • CUSTOM_ELEMENTS_SCHEMA is only needed when your own template places a raw gui-* tag. <gui-form> is a regular Angular component.
  • A raw gui-* tag in your own template gets no defer-hydration. Bind [attr.defer-hydration] yourself: '' when isPlatformServer(inject(PLATFORM_ID)) and null otherwise, so the element stays empty until the first client change detection pass.
  • Keep the app zoneless on hosts that evaluate the server entry inside a Vite dev server. See the Analog guide.

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 an Angular 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.