Skip to content

Server rendering ​

Three kinds of page are rendered on the server 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, /library, /settings, /login, /oauth/authorize, /moderation) is served as the static SPA shell.

Both hosts do this: the Cloudflare Worker (handleFrontend in apps/api/src/routes/render.js) and the self-hosted Node entry (apps/api/src/node/server.js) call the same shouldServerRender / serverRender pair from apps/api/src/routes/ssr.js.

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 forms rather than matching any extra segment, so it and the SPA's route table agree about what a lesson URL is: a wildcard would 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 server's copy of the route table, and ssr.js imports it (with HUB_PATH and PROFILE_PATH), so 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 server 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 server 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 behavior 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 personalized 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. A private draft 404s on the server for the same reason, and its author sees it after hydration, when their own authenticated fetch runs.

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 (`build:ssr` -> dist-ssr/)
apps/web/src/lib/ssr.jsx          the client/server handoff (SsrProvider, useServerData, useSiteOrigin)
apps/api/src/routes/ssr.js        the server route: match, fetch, render, splice
apps/api/src/routes/spa.js        the route table, and the asset/shell/404 fall-through
  1. The frontend handler asks shouldServerRender: a GET whose Accept includes text/html, to one of the routes above, without the prerender browser's __prerender flag.
  2. serverRender configures core from the server's own bindings (so hasSupabase() agrees with what the client will compute), 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. The stream is read to the end rather than streamed out, so the status and the head are settled before a byte is sent.
  4. The server injects the body into <div id="root"> and serializes the data into window.__SSR__. The response is sent with Cache-Control: no-cache and an X-Rendered: ssr header, which is the quickest way to tell a rendered page from the plain shell.
  5. src/main.jsx reads that, deletes it from window, and calls hydrateRoot instead of createRoot.

Failure at request time is always soft. A failed data fetch or a render error falls through to the static shell, which is what the app served before any of this existed. A missing server bundle is not a runtime case: ssr.js imports it statically, so the Worker fails to bundle without it, which is why every script that reaches wrangler builds it first.

One consequence is worth knowing: a lesson that genuinely does not exist takes that same soft 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 server 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. The same file builds the lesson and hub schemas (buildLessonCourseSchema, buildLessonListSchema).

Things that had to change to make it work ​

  • index.html's site-wide social defaults are stripped on a rendered page (its <title>, description, og:* and twitter:* tags), before the page's own tags go in. Otherwise a scraper reading the first og:title would get the generic one.
  • The service worker must not answer these navigations. navigateFallback would otherwise serve the precached shell and the server would never be asked, silently disabling SSR for exactly the returning visitors whose browsers have the shell cached. The routes are in WORKER_PATHS in apps/web/vite.config.js; see Installable app & offline use.
  • ColorSchemeProvider doesn't read 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 colors were already correct before 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.
  • Translations get an instance per render. entry-server.jsx creates a fresh i18next instance from baseConfig in src/lib/i18n.js with lng: "en", because module scope in a Worker is shared by concurrent requests. See Internationalization.
  • 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.
  • Splicing uses function replacements. Everything spliced into the shell carries user text, and in a string replacement $' and friends are substitution syntax, so a lesson titled $' would duplicate the rest of the page.

What SSR replaced, and what it didn't ​

apps/api/src/routes/render.js uses headless Chromium (the BROWSER binding in apps/api/wrangler.jsonc) for two unrelated jobs:

  • prerender(): an HTML snapshot for about 30 crawler user-agents (CRAWLER_UA). SSR replaces this for the routes above. It's still used for / (whose content is auth-gated, so an anonymous render is only ever the marketing splash) and for any SSR attempt that fails. The Node host has no Chromium and skips this step.
  • ogImage(): live 1200x630 screenshots for link previews. Unaffected. SSR cannot take a screenshot, so the binding stays.

Build order ​

The server 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 the API's deploy

The API package's dev, start, test and deploy scripts each run pnpm --filter @spelling-creator/web build:ssr first. In the Workers test project, the pool runs against a test-only entry (apps/api/src/collab-room.test-worker.js) that doesn't import the route table; the Node tests do import ssr.js, which is why the bundle has to exist.

The server build stubs out modules it can never run. SSR_UNREACHABLE in apps/web/vite.config.js lists them: the export engine, the git engine, the editor, moderation, login and OAuth pages, and the on-device model engines (translation, summary, import and read-aloud). They are all reached only through a dynamic import(), so the stub never has named exports to fail to provide; reaching one at render time throws, which means the route table and that list disagree.

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, run pnpm build and then pnpm dev:api, which serves the built assets in apps/web/dist through the real Worker (its dev script rebuilds the server bundle, but not the client).

Copyright © 2026 Spelling Creator.