Skip to content

inertia: full server-side rendering via a Node sidecar (deferred; design attached) #580

Open

Description

@antosubash

Backlog. #535 step 1 (a server-rendered <head>: Head, public_base_url, client re-emit) shipped in #574. Full server-side rendering was designed and then deferred. This issue keeps the design so it can be picked up later.

Design summary (reviewed 2026-10-22, not yet approved for build)

Which pages render on the server

  • A page opts in with inertia.render(PAGE, props, head=..., ssr=True). Landing would be first.
  • An opted-in page renders on the server for anonymous and signed-in visitors. Its props are already embedded in the HTML.
  • Admin and signed-in-only pages stay client-rendered.

Python

  • InertiaConfig.ssr_renderer hook. None or an exception falls back to client rendering, and an X-Inertia request never triggers SSR.
  • New simple_module_hosting/_ssr.py:
    • Sends a POST to {ssr_url}/render through a lifespan-scoped httpx.AsyncClient.
    • Checks the response shape. It must be {head: list[str], body: str}, with body starting <script data-page=.
    • Circuit breaker: after 3 failures in a row, skip SSR for 30 s.
    • Health check ssr reports DEGRADED when the sidecar is down.
  • New HostSettings:
    • ssr_url: empty means off. Restart-required, which also avoids a runtime SSRF knob.
    • ssr_enabled: per-request kill switch.
    • ssr_timeout_ms: default 1500.
  • Template fix: when the SSR head is used, it replaces head_tags; it is not appended. Suppress the brand <title> when the SSR head carries one.
  • Deprecate the dead InertiaConfig.ssr_url / ssr_enabled with a remove_in, and drop the manifest.py:50 coupling.

JS

  • host/client_app/ssr.tsx via @inertiajs/react/server (createServer, port 13714). Put the render function in ssr-render.tsx so it can be tested.

  • Move the shared createInertiaApp options into inertia-options.ts.

  • app.tsx calls hydrateRoot when the root element has data-server-rendered.

  • Vite SSR build (vite.ssr.ts, ssr.noExternal: true) into host/ssr-dist, never under static/. npm run build builds the client and SSR bundles together.

  • SSR-safety fixes:

    Where Fix
    i18next (one process-wide instance) Call configureI18n synchronously in SSR setup(), or use createInstance + I18nextProvider.
    app-title.ts (module-level appName) Set it per render.
    Year computed at module scope (BrandingFooter.tsx:5, AuthSplitAside.tsx:8) Compute it at render time.
    use-relative-time Avoid a hydration mismatch.
    BrandingHead brand colours, applied only in an effect Follow-up: render them server-side.

Operations

  • Docker ssr target: Node 24, non-root, HEALTHCHECK /health.
  • A compose ssr service with no host port, because of the sidecar's unauthenticated /shutdown. app gets SM_SSR_URL.
  • smpy docker add-ssr for scaffolded apps.
  • make dev unchanged; a new opt-in make dev-ssr.

Tests

  • Python: a fake renderer covering the fallbacks, the breaker, and no duplicate head.
  • Node-environment Vitest: render Landing on the server, and check no locale bleed between de and en.
  • e2e: the raw HTML contains the content, and the page works with JS disabled.

Trade-offs

  • Each opted-in page adds one round trip to Node, bounded by the timeout and the breaker. There is no render cache in version 1.
  • The server and client bundles must come from the same build.
  • Every render POST carries the full i18n catalogue.

https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions