# Server rendering architecture

Status: accepted, 2026-08-07

## Decision

SmolForge uses React 19 and React Router 8 Framework Mode for server rendering.
The existing `cloudforge-edge` Worker remains the single public HTTP owner: it
classifies API and protocol traffic, delegates those requests to the existing
Identity, Repository, and Deploy Control services, and renders page requests.
No product authority moves into the renderer.

The framework build produces two compatible artifacts:

- `packages/edge/build/server` contains the React Router server build and the
  generated Wrangler configuration.
- `packages/edge/build/client` contains the framework client assets plus the
  existing Vite SPA shell. The shell is the render-failure and rollout fallback.

The root toolchain pins Vite, esbuild, and the Cloudflare `workerd` version used
by the Vite plugin. The Vite pin gives root-hoisted Vitest helpers a stable peer
across workspaces; the esbuild and `workerd` pins keep npm clean installs from
pruning their platform-specific optional binaries when the repository also
contains older nested build tools.

Anonymous loaders receive typed service-binding adapters through React Router's
request context. They never receive browser tokens, cookies, or local-storage
state. Private and authenticated routes remain client-owned. Public and
unlisted records are loaded without credentials, so the existing service owner
continues to enforce visibility.

## Public data policy

- Public routes return semantic HTML and hydrate into the existing app.
- Unlisted Gists render for anyone holding the URL and emit `noindex, nofollow`.
- Private or unauthorized resources render only the client shell; their titles,
  filenames, and content are never serialized into HTML or loader data.
- Gist file excerpts share a 32 KiB UTF-8 byte budget per response.
- Repository and profile snapshots use an explicit presentation allowlist; no
  email or opaque authorization fields are serialized.

Static content routes (landing, docs, blog, changelog, and authentication form
shells) render their existing React components directly. Stateful public route
groups render typed server snapshots for the initial response, then hand over to
the existing client experience after hydration.

## Rollout and failure behavior

`SSR_ROLLOUT` has three states:

- `off`: serve the existing SPA shell.
- `canary`: render only recognized bots or requests with
  `X-Forge-SSR-Canary: 1`.
- `all`: render every document request.

The first production version used `canary`, which made Slack unfurls readable
without changing normal browser traffic. After the production canary passed,
the rollout advanced to `all`. A render exception or server 5xx is served from
the existing shell with `X-Forge-Render: static-fallback`. Rollback is the
previous immutable Cloudflare Worker version; changing traffic does not require
a data migration.

## Rejected alternatives

- A separate renderer Worker would duplicate hostname, asset, binding, and
  release ownership.
- TanStack Start would require a second routing contract while the product
  already depends on React Router throughout.
- Moving public reads to direct D1 access would bypass the established service
  owners and visibility policy.
- Raising long-running dispatch leases would hide ownership bugs. The build
  outbox event is delivered after workflow dispatch; release and workflow-run
  ledgers own the remaining build duration.
