Server rendering
Three routes are rendered on the Cloudflare Worker before the browser runs any JavaScript, and then hydrated in place:
| Route | Data the server fetches | Rendered for |
|---|---|---|
/hub | fetchPublishedLessons() | Everyone |
/hub/:id | fetchLesson(id) | Everyone |
/hub/:id/<tab> | fetchLesson(id) | Everyone |
/users/:id | fetchUserProfile(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
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-throughhandleFrontend(apps/api/src/routes/render.js) asksshouldServerRender.serverRenderfetches the page's data (through the very same@spelling-creator/coremodules the browser calls, so the two paths can't drift) and callsrender()from the server bundle.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 anog:tag it finds in the body.- The Worker injects the body into
<div id="root">and serialises the data intowindow.__SSR__. src/main.jsxreads that, and callshydrateRootinstead ofcreateRoot.
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:titlewould get the generic one.- The service worker must not answer these navigations.
navigateFallbackwould 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 inWORKER_PATHSinapps/web/vite.config.js; see Installable app & offline use. ColorSchemeProviderno longer readslocalStorage/matchMediaduring 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 inindex.html.RichTextsanitizes 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-checksdangerouslySetInnerHTMLduring 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.jsxandsrc/entry-server.jsxmust 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;ServiceWorkerPromptrendersnulland 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 thebrowserbinding stays.
Build order
The Worker imports a build artifact, so apps/web must be built before the Worker is bundled:
pnpm build # client -> apps/web/dist, server -> apps/web/dist-ssr
pnpm deploy # runs the above, then build:docs, then wrangler deploypnpm --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.