Skip to content
Forms, switch product

Vue SSR

<GuiForm> from @golemui/gui-vue renders on the server with renderToString from vue/server-renderer and hydrates through createSSRApp. This page documents the contract for any Vue server. The Nuxt guide shows the Nuxt wiring. Requires GolemUI 1.5.0 or newer.

The server renders the Vue layer: the <form>, the layouts and every gui-* tag. Vue reflects the primitive props of a custom element as attributes, so the tags arrive with their value, label and similar attributes set (<gui-textinput value="Ada" label="First name">). The Lit elements behind those tags render nothing on the server, and Lit renders their internals once the browser upgrades them.

The Vue adapter does not use the defer-hydration hold. The elements upgrade and render as soon as the browser defines them, before or after Vue hydrates, and hydration completes without warnings and reuses the server DOM. The widgets resolve synchronously in setup() through the preloaded registry. A widget that was not preloaded logs the warning from the overview during the server render and renders empty.

Widgets load through dynamic imports, and the server cannot wait for them while it renders. Preload them before the app is created, in a factory both entries share. See Preload the widgets for the registry rules.

src/create-app.ts
import { preloadFormWidgets } from '@golemui/core';
import { widgetLoaders } from '@golemui/gui-vue';
import { createSSRApp, type App } from 'vue';
import Root from './App.vue';
export async function createApp(): Promise<App> {
await preloadFormWidgets({ widgetLoaders });
return createSSRApp(Root);
}
src/entry-server.ts
import { renderToString } from 'vue/server-renderer';
import { createApp } from './create-app';
export async function render(): Promise<string> {
return renderToString(await createApp());
}
src/entry-client.ts
import { createApp } from './create-app';
// Mounting a createSSRApp() app onto server markup hydrates it.
createApp().then((app) => app.mount('#root'));

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.ts"></script>
</body>
</html>
src/styles.css
@import '@golemui/gui-components/index.css';

<GuiForm> needs no compiler configuration. Only a component of your own that places a raw gui-* tag in its template needs the Vue compiler to leave those tags alone:

vite.config.ts
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: { isCustomElement: (tag) => tag.startsWith('gui-') },
},
}),
],
});

Object props on such a tag must be bound with .prop (<gui-select :options.prop="languages">). Bound as an attribute, the server stringifies them into the markup.

Keep the form config in the component’s module scope or in <script setup>, with a formName.

src/App.vue
<script setup lang="ts">
import type { FormSubmitEvent } from '@golemui/core';
import { GuiForm } from '@golemui/gui-vue';
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);
</script>
<template>
<GuiForm :config="config" @form-submit="onFormSubmit" />
</template>
src/App.vue
<script setup lang="ts">
import type { FormSubmitEvent } from '@golemui/core';
import { GuiForm } from '@golemui/gui-vue';
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);
</script>
<template>
<GuiForm :config="config" @form-submit="onFormSubmit" />
</template>

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.ts emits the server bundle.

Terminal window
vite build # client bundle and index.html
vite build --ssr src/entry-server.ts # 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.ts'). The complete server, with both modes, is apps/vue-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 and Vue’s hydration comment markers removed; everything else is verbatim.

<form id="harness-form" novalidate dir="ltr">
<!-- layout wrappers omitted -->
<div class="gui-textinput gui-field">
<gui-textinput
uid="firstName"
label="First name"
touched="false"
required
value="Ada"
placeholder="Ada"
></gui-textinput>
</div>
<!-- the other fields look the same -->
<div class="gui-button gui-field">
<gui-button
uid="event_0"
actionType="submit"
label="Create account"
invalid="false"
variant="filled"
iconPosition="left"
></gui-button>
</div>
</form>

The form, the layouts and the field wrappers are there, and every gui-* tag carries its primitive props as attributes: value, label, placeholder, required. The element internals (the <input>, the <label>) appear once the browser upgrades 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 pass the HTML Vue rendered through renderElementsInHtml:

src/entry-server.ts
import { renderElementsInHtml } from '@golemui/gui-components/ssr';
import { renderToString } from 'vue/server-renderer';
import { createApp } from './create-app';
export async function render(): Promise<string> {
return renderElementsInHtml(await renderToString(await createApp()));
}

Each element renders from the attributes Vue gave it, and replaces that content with its live render when it upgrades. A value the adapter binds as a property, such as the options of a select, is not in the HTML: the select shows its placeholder until it upgrades. What the elements render on the server, and its limits, are in Server rendering in the GolemUI Components docs.

  • formName is optional on Vue 3.5 and newer, where the adapter derives a stable id on both sides. Set it when the id in the markup must be predictable, and always on older Vue versions.
  • 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 setup(). Keep the handler free of DOM access.
  • i18next is a module-level singleton. Scope the translator per request when the server serves several languages. See i18n.

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.