Render public HTML
SSR owns the document and request boundary. Your application owns the pages, router, data and client hydration.
On this page
Source-backed MarkdownSet up a small public page
Use the installed framework and tools from Installation, then install fastify@5. SSR is an optional module, not an App service. This lab renders public HTML without Vue or hydration so the request/document boundary is easy to see.
npm install fastify@5Copy the server and runner
The server factory does not listen. The runner exercises it through Fastify injection and closes it afterwards.
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/ssr/examples/. examples/Run it
Expect 200 for a page and recovery, 500 for the controlled failure, 404 for the unknown API route, and true isolation, safe-state, literal-markup and private-error flags.
npx tsx examples/runPublicPages.tsOwn the routes and template
fastifySsr() receives explicit routes, a document template and a renderer. /api/health stays an ordinary JSON endpoint; unknown API paths do not receive an HTML success page.
The app marker is required. Head and state markers are optional for deliberately non-hydrating documents. The renderer returns trusted appHtml plus optional status, headers, head or state overrides. The framework does not sanitize arbitrary HTML.
import Fastify, { type FastifyInstance } from 'fastify';
import { SSR_APP_MARKER, SSR_HEAD_MARKER, SSR_STATE_MARKER } from '@db3.ai/app/ssr';
import { fastifySsr } from '@db3.ai/app/ssr/fastify';
/**
* Creates a public HTML route with isolated metadata and escaped JSON state.
*
* The literal body is trusted application markup. Request input goes only into
* the escaped head/state serializers, never raw HTML. This non-hydrating lab
* has no Vue dependency; an application may hydrate the same state separately.
* @returns Server owned and closed by the caller, without a listening socket.
*/
export async function createPublicPageServer(): Promise<FastifyInstance> {
const server = Fastify({ logger: false });
const template = `<!doctype html><html lang="en"><head><meta charset="utf-8">${SSR_HEAD_MARKER}</head><body>${SSR_APP_MARKER}<script id="page-state" type="application/json">${SSR_STATE_MARKER}</script></body></html>`;
server.get('/api/health', async () => ({ ok: true }));
await server.register(fastifySsr({
template,
routes: ['/hello', '/broken'],
/** Collects state on the request-owned context and returns trusted markup. */
async render(context) {
const url = new URL(context.request.url, 'https://notes.example.test');
if (url.pathname === '/broken') throw new Error('Controlled private render failure');
const name = (url.searchParams.get('name') || 'Developer').slice(0, 200);
context.state.name = name;
context.head.title = `Hello ${name}`;
await Promise.resolve();
return { appHtml: '<main><h1>Your app is running</h1><p>Literal $& stays literal.</p></main>', headers: { 'Cache-Control': 'private, no-store' } };
},
}));
return server;
}
Keep state isolated and safe
Each render gets a new context with state, head metadata and a module set. Never share a mutable Vue app, router, store or authentication state between requests. In a Vue integration, create them inside each render.
Hydration state is escaped JSON inside an application/json script. Parse its text content; do not evaluate it. Escaping does not make secrets safe to send to a browser. Only serialize data that the current user may receive.
Head attributes are escaped. Inline head scripts and body HTML must come from trusted application code. The lab puts request input only into escaped title/state values and checks that literal replacement-token text is preserved.
import { pathToFileURL } from 'node:url';
import { createPublicPageServer } from './createPublicPageServer';
/** Extracts this lab's JSON script contents without evaluating executable code. */
export function pageState(html: string): { name: string } {
const match = html.match(/<script id="page-state" type="application\/json">([\s\S]*?)<\/script>/);
if (!match) throw new Error('Missing page-state JSON.');
return JSON.parse(match[1]!);
}
/** Runs real SSR requests, safe state, isolated concurrent renders and error recovery. */
export async function runPublicPages() {
const server = await createPublicPageServer();
try {
const [ada, grace] = await Promise.all([server.inject('/hello?name=Ada'), server.inject('/hello?name=Grace')]);
const unsafe = '</script><script>alert(1)</script>$&';
const escaped = await server.inject(`/hello?name=${encodeURIComponent(unsafe)}`);
const broken = await server.inject('/broken');
const recovered = await server.inject('/hello');
const missingApi = await server.inject('/api/missing');
return { status: ada.statusCode, isolated: pageState(ada.body).name === 'Ada' && pageState(grace.body).name === 'Grace', safeState: pageState(escaped.body).name === unsafe && !escaped.body.includes('</script><script>alert(1)'), literalMarkup: ada.body.includes('Literal $& stays literal.'), failure: broken.statusCode, privateError: broken.body === 'Internal Server Error', recovered: recovered.statusCode, missingApi: missingApi.statusCode };
} finally { await server.close(); }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runPublicPages(), null, 2));
Add Vue and Vite when the app needs them
@db3.ai/app/ssr/vite supplies fastifyViteSsr(). It owns development middleware, transformed templates, server entry loading, production client/server outputs, static assets and manifest-based preload links.
The application still supplies Vue entries, router and data loading through its HTTP API. Configure explicit routes and build paths; an SSR adapter does not turn every SPA route into a server-rendered page. Keep the server bundle out of the public asset directory.
The detailed source-backed reference and API show Vite options. This lab does not install a Vue app or prove hydration/build behavior; the framework has separate Vite lifecycle tests, and this documentation website uses the adapter. A standalone Vue/Vite walkthrough is a follow-up.
Make failure and cache policy deliberate
A renderer exception is logged server-side and becomes plain Internal Server Error with status 500. Do not return internal stack traces. The lab deliberately fails one request and proves the next request still renders.
Use status 404 and suitable robots metadata for missing pages. Set cache policy from the application’s data sensitivity. The lab uses private, no-store; public cache headers require genuinely public content and an invalidation policy.
Testing
Create a tests/ssr directory and save the test below as runPublicPages.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.
Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout.
import { expect, it } from 'vitest';
import { runPublicPages } from '../../examples/runPublicPages';
it('renders isolated pages with literal markup, safe state and private failure recovery', async () => {
expect(await runPublicPages()).toEqual({ status: 200, isolated: true, safeState: true, literalMarkup: true, failure: 500, privateError: true, recovered: 200, missingApi: 404 });
});
Run and extend the test
Change the page title or add an explicitly owned route. Keep concurrent-request, literal text, escaped state and unknown-API assertions. Injection does not prove proxy configuration, client hydration or browser cache behavior.
npx vitest run tests/ssr/runPublicPages.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.tsCoverage and API
Tested: real Fastify adapter, route ownership, HTML/head/state rendering, concurrent isolation, safe state and private failure/recovery. Explained/reference: Vue/Vite integration, manifests, static delivery, hydration and deployment policy.
Renders concurrent public pages, round-trips dangerous-looking JSON safely, preserves literal markup and recovers from a private render failure.
The guide test passes against the real framework components.packages/app/src/ssr/tests/examples/runPublicPages.test.tsThis test command requires the framework repository. Use the walkthrough commands in an installed application.
Environment: Fastify injection and Node.js; no SQL, Vue build, provider or listening port.