# Render public HTML

> SSR owns the document and request boundary. Your application owns the pages, router, data and client hydration.

- Package: `@db3.ai/app/ssr`
- Canonical page: [https://db3.ai/docs/ssr](https://db3.ai/docs/ssr)
- Markdown: [https://db3.ai/docs/ssr.md](https://db3.ai/docs/ssr.md)
- Framework source of truth: `packages/app/src/ssr/README.md`

<a id="setup"></a>

## Set 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.

- [Installation](https://db3.ai/docs/installation.md)

### Install the HTTP adapter

```bash
npm install fastify@5
```

<a id="copy"></a>

## Copy the server and runner

The server factory does not listen. The runner exercises it through Fastify injection and closes it afterwards.

### Copy the shipped example

```bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/ssr/examples/. examples/
```

<a id="run"></a>

## 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.

### Run the lab

```bash
npx tsx examples/runPublicPages.ts
```

<a id="render"></a>

## Own 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.

### examples/createPublicPageServer.ts

```typescript
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;
}
```

<a id="state"></a>

## 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.

### examples/runPublicPages.ts

```typescript
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));
```

<a id="vite"></a>

## 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.

<a id="errors"></a>

## 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.

<a id="testing"></a>

## 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.

### tests/ssr/runPublicPages.test.ts

```typescript
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 });
});
```

<a id="run-tests"></a>

## 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.

### Run your copied test and check types

```bash
npx vitest run tests/ssr/runPublicPages.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts
```

<a id="coverage"></a>

## Coverage 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.

- [SSR API and adapters](https://db3.ai/docs/ssr-api.md)

## Behavioural verification
Renders concurrent public pages, round-trips dangerous-looking JSON safely, preserves literal markup and recovers from a private render failure.
- Behaviour test: `packages/app/src/ssr/tests/examples/runPublicPages.test.ts`
- Repository test command (framework checkout only): `npm run test:service --workspace @platform/app -- ssr --maxWorkers=1`
- In an installed application, use the walkthrough commands instead of this repository test.
- Expected outcome: The guide test passes against the real framework components.
- Environment: Fastify injection and Node.js; no SQL, Vue build, provider or listening port.

## Related documentation
- [Application URLs](https://db3.ai/docs/url.md): Give email, workers and HTTP routes one trusted application address. A URL resolver is not a redirect policy.
- [Create your first app](https://db3.ai/docs/create-app.md): Start an HTTP server, return a JSON response, reject invalid input and test it without opening a port.
- [SSR API reference](https://db3.ai/docs/ssr-api.md): Current emitted signatures and options for @db3.ai/app/ssr.

## Framework-owned source: `packages/app/src/ssr/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Server-side rendering

## Runnable public-page boundary

Install `fastify@5`, copy the package's `src/ssr/examples/` into `examples/`,
then run `npx tsx examples/runPublicPages.ts`. The injectable server renders
isolated requests, safely embeds JSON state, preserves literal replacement-token
text, rejects unknown API routes and recovers after a private render failure.
It needs no SQL, listening port, Vue build or provider credentials. The exact
consumer test is `tests/examples/runPublicPages.test.ts`. It proves the adapter
boundary, not a complete Vue hydration or production proxy configuration.

Document substitution treats rendered values literally, including `$&` and
marker-like text in application output. Body markup still must be trusted;
literal insertion is not HTML sanitization.

`@db3.ai/app/ssr` provides the transport-neutral boundary for rendering public
HTML. It deliberately does not live on `App`: SSR is an optional web delivery
concern, while queues, schedulers, APIs and other processes can continue using
the framework without Vue, Vite or Fastify SSR dependencies.

The initial framework component owns:

- one isolated render context per request;
- document head and render-result contracts;
- safe JSON hydration-state serialization;
- deterministic HTML document assembly;
- an optional Fastify adapter with its own error boundary;
- a first-class Vite/Fastify lifecycle adapter for development and production.

The host application owns its Vue app, router, page data loading, client and
server entries. The optional Vite adapter owns development middleware,
production bundle loading, manifest links, and static asset registration. SSR
data should still be loaded through the application's HTTP API rather than
importing app models into the frontend server entry.

## Document template

The application marker is required. Head and state markers are optional for
documents that intentionally do not hydrate in the browser.

```html
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
		<!--platform-ssr-head-->
	</head>
	<body>
		<div id="app"><!--platform-ssr-app--></div>
		<script id="__PLATFORM_SSR_STATE__" type="application/json"><!--platform-ssr-state--></script>
		<script type="module" src="/src/entry-client.ts"></script>
	</body>
</html>
```

The state marker receives escaped JSON, not executable JavaScript. The client
entry can parse it before hydrating:

```ts
const element = document.querySelector('#__PLATFORM_SSR_STATE__');
const state = JSON.parse(element?.textContent || '{}');
```

## Fastify adapter

Install Fastify in the host application and register the optional adapter:

```ts
import Fastify from 'fastify';
import { fastifySsr } from '@db3.ai/app/ssr/fastify';
import template from './index.html?raw';
import { renderPage } from './src/entry-server';

const server = Fastify();

server.get('/api/health', async () => ({ ok: true }));

await server.register(fastifySsr({
	template,
	render: renderPage,
	routes: ['/blog', '/blog/*'],
	shouldRender: (request) => {
		const path = request.url.split('?')[0];
		return path !== '/api' && !path.startsWith('/api/');
	},
}));
```

Prefer explicit `routes` when SSR owns a contained public surface. When omitted,
the adapter retains its `/*` fallback for applications whose complete page
surface is server rendered. Fastify's specific routes take precedence over that
fallback. `shouldRender` remains an additional guard for reserved route prefixes
so an unknown API route does not accidentally receive an HTML page.

The application renderer returns markup and can collect status, head metadata
and hydration state on its request-owned context:

```ts
import type { SsrRenderer } from '@db3.ai/app/ssr';

export const renderPage: SsrRenderer = async (context) => {
	context.head.title = 'Example App';
	context.state.page = await loadPageThroughHttpApi(context.request.url);

	return {
		appHtml: renderApplicationMarkup(context.state.page),
	};
};
```

Create a fresh Vue application, router and store inside every call to the
renderer. Module-level mutable auth, router or store state can leak between
concurrent requests and must not be used.

## Vite and Fastify integration

`@db3.ai/app/ssr/vite` provides the complete Vite lifecycle while retaining
explicit route ownership:

```ts
import Fastify from 'fastify';
import { fastifyViteSsr } from '@db3.ai/app/ssr/vite';

const server = Fastify();

await server.register(fastifyViteSsr({
	root: import.meta.dirname,
	routes: ['/blog', '/blog/*'],
	viteConfigFile: 'vite.marketing.config.ts',
	template: 'marketing.html',
	developmentEntry: '/src/marketing/entry-server.ts',
	preloadJavaScript: false,
	staticAssets: {
		prefix: '/site-assets/',
		directory: 'dist/client/site-assets',
	},
}));
```

`viteConfigFile` lets one application keep its SPA and SSR marketing graphs
separate while sharing the same source tree. The SSR adapter uses that config
for development transforms; production continues loading the explicit client
and server output paths supplied by the application.

In development the adapter uses Vite middleware mode, transforms the HTML shell
per request, hot-loads `/src/entry-server.ts`, and makes Vite's source, module,
dependency, filesystem, and HMR URL namespaces available to the browser. Unknown
page URLs still fall through to the application's ordinary not-found boundary.
In production it reads `dist/client/index.html`, imports
`dist/server/entry-server.js`, serves the configured asset directory, and maps
`context.modules` through Vite's SSR manifest. Set `preloadJavaScript: false`
for HTML/CSS-only pages; hydrating apps can retain the default module-preload
behavior.

This makes SSR designation deliberate and visible: a page is server-rendered
only when the application router implements it and its URL appears in the
adapter's `routes` list. An existing SPA can continue owning every other route.
````

## Guidance for AI tools
Use the documented public import `@db3.ai/app/ssr` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
