Skip to content
Forms, switch product

Astro

Astro renders the complete GolemUI form on the server through @golemui/lit/ssr, widget internals included, with no framework in between. The frontmatter renders the form to HTML and a client script resumes it. Verified with Astro 7 and Lit 3. The contract is on the Lit SSR page; this page is the wiring. Requires GolemUI 1.5.0 or newer.

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

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

Terminal window
npm i @golemui/core @golemui/gui-shared @golemui/lit @golemui/gui-lit @golemui/gui-components @golemui/gui-validators --save
npm i @lit-labs/ssr@^4.1.0 @astrojs/node --save

@lit-labs/ssr, version 4.1.0 or newer, is the optional peer dependency that powers the server render. @astrojs/node is only needed to render per request.

Render every page on request with the Node adapter, or drop output and adapter to run the same Lit render once, at build time, under Astro’s default static output.

import node from '@astrojs/node';
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});

With output: 'server', a page can still opt into build-time rendering with export const prerender = true; in its frontmatter.

Keep the form config in a module both the frontmatter and the client script import, so both sides build the form from the same definition. Nothing in it may touch the DOM, and formName is mandatory.

src/form.ts
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' }),
];
export const config: GuiFormInitConfig = {
formName: 'signup', // mandatory: same form id on the server and the client
formDef,
};
src/form.ts
import formDef from './signup.json';
export const config = {
formName: 'signup', // mandatory: same form id on the server and the client
formDef,
};

The frontmatter runs on the server only. Preload the widgets, render the form to a string and inject it with set:html. Every element in the output carries defer-hydration and stays inert until the client script resumes it. Import the stylesheet here too: GolemUI never injects it, and an import in the frontmatter puts it in the page head.

src/pages/index.astro
---
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-lit';
import { renderTemplate } from '@golemui/lit/ssr';
import { html } from 'lit';
import { config } from '../form';
import '@golemui/gui-components/index.css';
await preloadFormWidgets({ widgetLoaders });
const formHtml = await renderTemplate(html`<gui-form .config=${config}></gui-form>`);
---
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8" />
</head>
<body>
<main>
<Fragment set:html={formHtml} />
</main>
</body>
</html>

The page <script> preloads the widgets again, attaches the listeners, and resumes the form. The resume replaces the server markup with one live render before the next paint and removes the defer-hydration hold; it does not hydrate the server markup. Listeners and properties go on before the resume, so the events of the first render are not missed.

<script>
import { preloadFormWidgets, type FormEvent } from '@golemui/core';
import { widgetLoaders, type FormElement } from '@golemui/gui-lit';
import { initValidators } from '@golemui/gui-validators';
import { resumeServerRenderedForm, type FormElement as CoreFormElement } from '@golemui/lit';
import { config } from '../form';
await preloadFormWidgets({ widgetLoaders });
const form = document.querySelector<FormElement>('gui-form')!;
form.addEventListener('formEvent', (event) => {
const { name, data } = (event as CustomEvent<FormEvent>).detail;
if (name === 'handleSubmit') console.log('Submitted:', data);
});
resumeServerRenderedForm(form as unknown as CoreFormElement, {
config: config as never,
validators: initValidators(),
});
</script>

resumeServerRenderedForm is typed for the core form element. <gui-form> follows the same contract with the widget set config, hence the casts, and builds its own validators.

  • formName is mandatory.
  • Import @golemui/lit/ssr from the frontmatter or other server code only. It has no browser build.
  • Listeners and properties (formHealthBoundary, autocomplete) go on the element before resumeServerRenderedForm.
  • Handlers never run on the server. onLoad fires once the widget has rendered in the browser.

Register your own Lit widgets with safeDefine from @golemui/lit, so they render on the server and honor the defer-hydration hold. Keep the loaders in one module-scope object, spread it into both preload calls and pass the same object as customWidgetLoaders. See Custom widgets in Lit SSR.