Askr documentation
Guides

Add SSR to an SPA

Add SSR to an SPA: a worked guide from route registry through to a production build.

Example

Reuse the SPA route registry on the server, serialize its initial data into the document, and switch the browser entry from createSPA to hydrateSPA.

import { hydrateSPA } from '@askrjs/askr/boot';
import { renderToString } from '@askrjs/askr/ssr';

// Server entry
const html = renderToString({ registry, url: request.url });

// Browser entry
await hydrateSPA({ root: document.getElementById('app')!, registry });

Goal and architecture

Adding SSR means the same route registry now renders on the server first and hydrates in the browser, instead of mounting cold. The client swaps `createSPA` for `hydrateSPA({ root, registry })`, and the server calls `renderToString()` with your route registry or a `RouteRenderOptions` object to produce the initial HTML. `@askrjs/vite`'s `askrServer({ entry })` plugin is the piece that's new here — it owns wiring your server entry module into the Vite dev/build pipeline.

Implementation

Your HTML template needs exactly one `<!--askr-head-->` marker inside `<head>` and one `<!--askr-app-->` marker where the app mounts — the server plugin validates both and refuses to serve a template missing either one. `insertAskrFragment()` and `composeAskrHead()` are the low-level primitives the plugin uses internally to compose the document at those markers. Point `askrServer({ entry: 'src/server/entry-server.ts' })` at a module that exports a plain `ServerApp` — the plugin wraps it with `createDocumentApp()` internally, you don't call that function yourself. Vite owns the document end to end: it preserves your authored head content and injects only Askr-owned title/meta/link/JSON-LD nodes at the marker.

Failure states

On `hydrateSPA`, set `hydrate.verifyMarkup` during development to catch cases where server-rendered markup doesn't match what the client would produce — silent hydration mismatches are far more expensive to debug than a loud one. If a route has a `loader`, an error there should surface through the same `ErrorBoundary` your client-only errors go through, not a raw 500 with no fallback UI. Watch for routes with `auth` requirements: the SSG's `RouteConfig.auth` field marks auth-gated routes as runtime-only by default — the equivalent `renderToString()`/`RouteRenderOptions` call for SSR doesn't carry its own `auth` field, so that auth-aware runtime-only exclusion is a `resolveRequest({ auth })` concern instead. Either way, SSR needs to resolve auth context per request rather than reuse a cached one.

Verification

View source on a rendered route and confirm the markup is real content, not an empty shell waiting for JS — that's the entire point of doing this work. Throttle the network tab and watch hydration complete without a flash of unstyled or duplicated content. Then disable JavaScript entirely and confirm the initial page is still readable; if it isn't, something in your entry-server module is producing markup the static HTML path never exercises.