Skip to content

Server rendering ​

Three routes are rendered on the Cloudflare Worker before the browser runs any JavaScript, and then hydrated in place:

RouteData the server fetchesRendered for
/hubfetchPublishedLessons()Everyone
/hub/:idfetchLesson(id)Everyone
/hub/:id/<tab>fetchLesson(id)Everyone
/users/:idfetchUserProfile(id)Everyone

Everything else (/, /editor, /login, /oauth/authorize, /moderation) is served as the static SPA shell exactly as before.

A lesson's tabs (/hub/:id/practice, /discussion, /proposals, /proposals/:prId, /history; see Pages & routing) are all the same lesson, so one fetchLesson serves all of them and the tab decides what to draw with it. LESSON_PATH names those five forms rather than matching any extra segment, so it and the SPA's route table agree about what a lesson URL is: a wildcard let /hub/:id/anything through, costing a lesson fetch and a full render to produce a page that isn't one. Adding a tab means editing both. The pattern itself lives in apps/api/src/routes/spa.js, the Worker's route table, shared so that the code deciding a path is a lesson and the code deciding it is a page at all cannot disagree. LessonLayout seeds its state from that payload and the tab reads it through useLesson(), so no tab fetches the lesson a second time.

Matching the tabs is not optional. The service worker's navigateFallback denylist excludes the whole of /hub/* from the precached shell (WORKER_PATHS in apps/web/vite.config.js) precisely because the Worker answers those paths itself. A tab that this route failed to match would be excluded from the shell and left unrendered: online it falls through to the SPA shell and still works, offline there is nothing to fall through to and it fails outright. The two lists have to stay in step, and both say so in a comment.

The bootstrap payload is keyed by the exact url.pathname the Worker rendered, and useServerData only hands it to a page still on that path, so following a tab link releases it and the layout keeps serving its own state, which is the behaviour you want.

Why only these three ​

Because their primary content is a public read. The Supabase session lives in localStorage behind PKCE and is invisible to a server, so the render is always anonymous; the personalised parts of the page (the account menu, an author's Edit/Delete controls, whether you follow a profile) fill in when the client hydrates. No session ever has to move to a cookie, which is what keeps this tractable.

fetchLatestLessons and fetchUserActivity can't be server-rendered at all: they parse Atom with DOMParser, and live in @spelling-creator/core/browser/feeds. Both are dashboard content, so hydrating them is correct.

How it fits together ​

text
apps/web/src/entry-server.jsx     the app, built for workerd (`pnpm build:ssr` -> dist-ssr/)
apps/web/src/lib/ssr.jsx          the client/server handoff (SsrProvider, useServerData)
apps/api/src/routes/ssr.js        the Worker route: match, fetch, render, splice
apps/api/src/routes/spa.js        the route table, and the asset/shell/404 fall-through
  1. handleFrontend (apps/api/src/routes/render.js) asks shouldServerRender.
  2. serverRender fetches the page's data (through the very same @spelling-creator/core modules the browser calls, so the two paths can't drift) and calls render() from the server bundle.
  3. render() returns { head, body }. React hoists <title>/<meta> to the front of its output when rendering a subtree rather than a whole document, so they're split off and spliced into the real <head>; a scraper won't read an og: tag it finds in the body.
  4. The Worker injects the body into <div id="root"> and serialises the data into window.__SSR__.
  5. src/main.jsx reads that, and calls hydrateRoot instead of createRoot.

Failure is always soft. A failed data fetch, a render error, a missing server bundle: each falls through to the static shell, which is what the app served before any of this existed.

One consequence is worth knowing: a lesson that genuinely does not exist takes that same path, because a 404 from the API and a timeout reaching it arrive here as the same thrown error. So /hub/<deleted-id> is still answered 200 with the shell, and the app reports the miss after it hydrates, unlike a path that isn't a route at all, which the Worker 404s outright (Unknown paths).

Page metadata ​

src/lib/seo.jsx exports a <DocumentMeta> component, not a hook. React 19 hoists <title>, <meta> and <link> into <head> from anywhere in the tree, so page metadata is ordinary JSX, which is exactly what makes it work under SSR, where an effect that writes into document.head never runs.

JSON-LD is deliberately not hoisted: React only hoists <script> when it's async, which is meaningless for a non-executable type. <JsonLd> renders in place, which search engines accept.

Things that had to change to make it work ​

  • index.html's site-wide social defaults are stripped on a rendered page, before the page's own tags go in. Otherwise a scraper reading the firstog:title would get the generic one.
  • The service worker must not answer these navigations. navigateFallback would otherwise serve the precached shell and the Worker would never be asked, silently disabling SSR for exactly the returning visitors whose browsers have the shell cached. The three routes are in WORKER_PATHS in apps/web/vite.config.js; see Installable app & offline use.
  • ColorSchemeProvider no longer reads localStorage/matchMedia during render. The server can't, and a hydrating client has to render the same thing the server did: the theme toggle shows a sun or a moon, so this is markup, not just a CSS variable. The stored choice is adopted in a layout effect, after hydration but before paint. Page colours were already correct pre-paint via the inline script in index.html.
  • RichText sanitizes only in the browser. DOMPurify needs a real DOM, so the rich-text branch renders nothing on the server and appears immediately after mount. Rendering the unsanitized HTML server-side is not an alternative: React never re-checks dangerouslySetInnerHTML during hydration, so whatever the server wrote is what the reader keeps. A profile bio still reaches crawlers, as the page's meta description.
  • src/main.jsx and src/entry-server.jsx must stay structurally in step. Anything that renders DOM has to appear in both, in the same order. Sonner's <Toaster> renders an empty <section> even with no toasts, so it's in both; ServiceWorkerPrompt renders null and is browser-only, so it isn't.

What SSR replaced, and what it didn't ​

apps/api/src/routes/render.js used headless Chromium for two unrelated jobs:

  • prerender(): an HTML snapshot for ~30 crawler user-agents. SSR replaces this for the three routes above. It's still the fallback for / (whose content is auth-gated, so an anonymous render is only ever the marketing splash) and for any SSR attempt that fails.
  • ogImage(): live 1200x630 screenshots for link previews. Unaffected. SSR cannot take a screenshot, so the browser binding stays.

Build order ​

The Worker imports a build artifact, so apps/web must be built before the Worker is bundled:

bash
pnpm build     # client -> apps/web/dist, server -> apps/web/dist-ssr
pnpm deploy    # runs the above, then build:docs, then wrangler deploy

pnpm --filter @spelling-creator/api dev builds the server bundle itself before starting wrangler dev. The Worker test suite is unaffected: it runs against a test-only entry (apps/api/src/collab-room.test-worker.js) that doesn't import the route table.

Local development ​

pnpm dev:web (the Vite dev server) does not server-render; there is no Worker in front of it, so every route arrives as the plain SPA shell and mounts with createRoot. To exercise SSR you need pnpm dev:api, which serves the built assets through the real Worker.

Copyright © 2026 Spelling Creator.