Askr
Guides

Add SSR to an SPA

Use add ssr to an spa with the current published Askr packages.

  • @askrjs/askr0.0.59

How to use add ssr to an spa

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

  1. Import the published @askrjs/askr entrypoint.
  2. Keep add ssr to an spa configuration next to the component, route, or server composition root that owns it.
  3. Handle pending, unavailable, cancellation, and failure states, then verify the production path.
import { hydrateSPA } from '@askrjs/askr/boot';
import { renderToString } from '@askrjs/askr/ssr';

// Server entry
const rendered = await 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 it uses internally; you normally don't call them directly, you just point `askrServer({ entry: 'src/server/entry-server.ts' })` at a module that exports a request handler built with `createDocumentApp()`. 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: `RouteRenderOptions` and the SSG's `RouteConfig.auth` field both mark auth-gated routes as runtime-only by default, and SSR needs to resolve that 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.