Server rendering
Collecting the CSS a render used, so the page arrives styled instead of styling itself later.
In the browser a component puts its stylesheet in the head the first time it renders, so a page only carries the components it uses and nothing has to be imported by hand. On a server there is no head to put it in, so the render collects the stylesheets instead and the application writes them into the document it sends.
The render
Call collectStyles(app) after renderToString, once every component has asked for what it needs. It returns the theme's custom properties followed by the stylesheet of each component that rendered, either as one css string or as tags, the <style> elements ready for the head.
import { createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
import { Vitral, collectStyles, colorSchemeTag } from '@vitral/vue';
import App from './App.vue';
export async function render() {
const app = createSSRApp(App).use(Vitral, { theme: { storageKey: 'app-scheme' } });
const html = await renderToString(app);
// After the render: by now every component has asked for its stylesheet.
const { tags } = collectStyles(app);
return { html, head: colorSchemeTag(app) + tags };
}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!--vitral-head-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html> The elements carry the same data-vitral-theme and data-vitral-style markers the browser writes, so hydration finds them and does not inject a second copy, and they carry the csp.nonce the plugin was given. Ids come from Vue's useId(), which gives the server and the client the same ones, so aria-controls and <label for> survive hydration.
Dark before the first paint
A remembered dark scheme is only known to the browser, so a server-rendered page would arrive light and turn dark once the bundle runs, which is a white flash. colorSchemeTag(app) writes a small script for the head that reads the remembered choice and the system preference, and marks <html> before anything is painted.
A server that already knows the scheme, from a cookie or a session or the user's account, needs no script and can render the mark itself:
import { colorSchemeAttrs } from '@vitral/vue';
// The server already knows the scheme, so nothing has to run before the paint.
const dark = request.cookies.get('app-scheme')?.value === 'dark';
const attrs = colorSchemeAttrs(dark); // { class: 'vt-dark' }, or {}Spread those attributes on <html>. Both follow darkModeSelector, so a custom selector needs no second edit.
What cannot be drawn on a server
A chart is drawn by measuring the element it is in, which no server can do. It still renders that element, an empty host, and fills it on mount, so the markup the browser hydrates is the markup it was sent. Holding a component back until after it has mounted (<ClientOnly>) is worse: its mounted then runs with nothing to draw in.
Overlays (dialogs, menus, tooltips) open on interaction, so they never render on the server either. They teleport once the page is alive.
Nuxt
The module does all of the above, and the auto-imports besides.
export default defineNuxtConfig({
modules: ['@vitral/nuxt'],
vitral: {
preset: 'Prism', // or '~/theme/brand', or false
colorScheme: 'system',
cookie: 'vitral-scheme', // where the reader's choice is kept
prefix: 'Vt' // '' registers <Button> instead of <VtButton>
}
});<template>
<VtButton label="Save" icon="check" @click="toast.add({ severity: 'success', summary: 'Saved' })" />
<VtForm.Root :initial-values="{ email: '' }">
<VtForm.Field name="email" label="Email" required>
<VtInputText type="email" />
</VtForm.Field>
</VtForm.Root>
</template>
<script setup lang="ts">
const toast = useToast(); // auto-imported, like every composable
</script> The scheme lives in a cookie rather than in localStorage, because only a cookie reaches the server. The page is then rendered in the scheme the reader picked, <html class="vt-dark"> and all. Under 'system', which no server can resolve, the module writes the script above instead.
Directives keep their plain names (v-tooltip, whatever the prefix) because they are registered on the application rather than imported. A template is compiled before any auto-import runs, so a registration is the only thing that reaches it.
Options
| Option | Type | Description |
|---|---|---|
| preset | 'Prism' | 'Ink' | 'Avalonia' | 'Simple' | 'Astra' | string | false | A shipped preset by name, a module that default-exports one, or false for no theme. |
| colorScheme | 'light' | 'dark' | 'system' | The scheme to start in, when the cookie says nothing. |
| cookie | string | false | Where the reader's choice is kept, so the server can read it. false forgets it between visits. |
| darkModeSelector | string | false | Where the dark scheme applies: a class, an attribute, 'system', or false. |
| locale | 'en' | 'ptBR' | string | false | A shipped locale by name, or a module that default-exports one. |
| prefix | string | The prefix on every registered component: 'Vt' by default, '' for <Button>. |
| components / composables | boolean | Register them globally. Off means importing from @vitral/vue by hand. |
| cssLayer, inputVariant, unstyled | string | false, 'outlined' | 'filled', boolean | The same options the plugin takes; see installation. |