Skip to content

Remove SSR from Cloud Portal #656

@mattdjenkinson

Description

@mattdjenkinson

Reduce SSR to improve TTFB and loading experience

Summary

Reduce the scope of Server-Side Rendering so that only the initial document request runs server-side logic. The server keeps handling cookies, session, CSRF, and env for that first request and returns a minimal HTML shell with that data embedded; all route loaders and full React tree rendering move to the client. Goal: better Time to First Byte (TTFB) and faster perceived load while keeping the existing cookie/session/CSRF flow on the server.

Motivation

  • TTFB: Stop running the full React render and every route loader on the server for each request; only run a small bootstrap (root-level cookies + env) and return HTML quickly.
  • Loading: App shell and bootstrap data (ENV, CSRF, toast) come from one fast server response; route-specific data loads on the client.
  • Keep server-side where it matters: Cookie-based session, toast, and CSRF stay on the server for the initial request so we don’t have to re-architect auth or security.

Architecture: minimal SSR + client-only routes

  • Initial document request (stays server-side)
    For the first HTML request only, the server:

    • Runs root-level logic: getToastSession, csrf.commitToken, reads env.public, and any session/auth checks needed to set cookies or headers.
    • Returns a minimal HTML document that:
      • Sets any required cookies (e.g. Set-Cookie from CSRF/toast).
      • Embeds bootstrap data in the page (e.g. window.ENV, initial toast, CSRF token) so the client doesn’t need a second request.
      • Includes <script> / <link> tags for the SPA bundle.
    • No server-side execution of route loaders; no full React tree render (or at most a single minimal shell render if we keep entry.server for that one request only).
  • All other behaviour (client-side)

    • Route loaders become client-side data fetching (e.g. TanStack Query, URQL, or fetch to API routes).
    • Route actions become client-side API calls.
    • Document is not re-rendered by the server on client-side navigations; the app behaves as an SPA after first load.

So: something stays SSR (the initial request + cookies/bootstrap data), but full app SSR goes away (no route loaders, no streaming of the full component tree on every request).

Current SSR surface

1. Keep on server (minimal SSR / bootstrap)

What Where Notes
Root-level cookie/session logic getToastSession, csrf.commitToken in root loader (or equivalent) Run once per initial document request; set cookies and embed result in HTML.
Public env env.public from app/utils/env/env.server.ts Embed in HTML as window.ENV (or similar) so client has it without an extra config request.
Initial HTML response Either a custom bootstrap route (e.g. Hono handler that runs root logic and returns HTML template) or a minimal entry.server that only runs root loader and renders a minimal shell (no route components). Must set cookies and include bootstrap data + script/link tags.
CSP nonce (if used) Generated on server for the initial document only. Optional: can switch to hash-based CSP or static nonce for SPA assets.

2. Remove or move off server

Location What New approach
react-router.config.ts Full ssr: true (all loaders + full tree on server) Use ssr: false or keep a single “shell” render that only runs root loader and does not run route loaders.
app/entry.server.tsx Full renderToPipeableStream of entire app with ServerRouter Replace with either: (a) minimal shell render that only injects root loader data and script/link tags, or (b) no React render on server — serve a static or templated HTML from a Hono route that runs root loader logic and injects data.
All route loaders Auth redirects, initial data for org/project/account/etc. Move to client: useQuery/fetch/URQL and/or redirect logic in client (or call existing API routes).
All route actions Logout, org/account mutations, etc. Client calls API routes; server keeps API routes and cookie/session handling there as today.
Root meta / links MetaFunction, linksFunction Set document title and critical meta client-side after load.
URQL SSR state urqlState from matches in root Remove; use client-only URQL.

3. Build & config

Location What
react-router.config.ts Set ssr: false if we go full SPA with custom bootstrap HTML, or keep a minimal SSR mode that only runs root.
vite.config.ts If we remove React render from server: drop or narrow reactRouterHonoServer, react-dom/server alias, and ssr.optimizeDeps as appropriate.
app/entry.client.tsx Use createRoot + client router if we no longer hydrate a server-rendered shell; otherwise keep hydrateRoot for the minimal shell.

4. Route loaders (move to client)

All route modules that export loader must be migrated to client-side data fetching or to API routes called by the client:

  • Root: app/root.tsx — see above.
  • Layouts: app/layouts/private.layout.tsx, app/layouts/public.layout.tsx — auth/session checks and redirects → do in client (e.g. auth context + redirect) or via a small session/me API.
  • Auth: app/routes/auth/* (callback, login, logout, index), app/routes/index.tsx, app/routes/project/index.tsx — redirects and session → client or API.
  • Invitation: app/routes/invitation/index.tsx — fetch invitation by id on client or via API.
  • Org: app/routes/org/detail/* — org, team, projects, settings, quotas, policy-bindings → replace with useQuery/fetch against existing or new API routes.
  • Project: app/routes/project/detail/* — project, home, config (domains, secrets), edge (proxy, dns-zones), metrics (export-policies), settings → same as above.
  • Account: app/routes/account/* — organizations, settings (notifications, security, active-sessions, etc.) → client fetch or API.
  • Test / misc: app/routes/test/permissions.tsx, app/routes/test/sentry.tsx, app/routes/waitlist/index.tsx — adapt as needed.

Where loaders today return initial data for lists/detail (e.g. projects, domains, secrets), use that data as the initial payload of a client-side query (e.g. TanStack Query or URQL) so the UI can show data as soon as it’s available without blocking TTFB.

4. Route actions (server-side form handling)

Replace server actions with client-side API calls (or keep a thin API route that the client calls):

  • app/routes/auth/logout.tsxaction → call logout API from client.
  • app/routes/org/detail/projects/index.tsxaction → API for alert/close or project mutation.
  • app/routes/account/organizations/index.tsxaction → API for organization mutation.
  • app/routes/test/sentry.tsx — test-only; can stay as API or be removed.

5. Server-only modules (keep for bootstrap)

  • Cookies/session: Keep getToastSession, csrf, and related server-only cookie usage; they run in the initial document handler only (root loader or equivalent). Response sets cookies and embeds toast/CSRF in the HTML so the client doesn't need a separate config request.
  • Env: Keep using app/utils/env/env.server.ts in the bootstrap pass; embed env.public in the initial HTML (e.g. window.ENV) so the client has it on load.
  • Auth/session: Session middleware and validation stay on the server; the initial request can still run session logic and set cookies. Route-level auth redirects move to the client (e.g. after reading session from bootstrap or a small /me/session API if needed).

6. Meta and SEO

  • MetaFunction usage across routes (e.g. app/root.tsx, app/routes/org/detail/settings/policy-bindings.tsx, and many others) — in SPA mode these no longer run on the server. Replace with client-side updates: e.g. document.title, or a small helper that sets <title> and meta tags from route config or route component.

7. URQL / GraphQL SSR state

  • app/root.tsx uses urqlState from matches (loader data) for SSR cache rehydration. In SPA mode, remove this; rely on client-side URQL only (no server-rendered GraphQL state).

8. Nonce and CSP

  • The initial document is still served by the server, so we can keep generating a nonce per request for that response and inject it into the minimal HTML (and CSP header). No change required for nonce/CSP if we keep a server-rendered or server-generated shell for the first request only.

9. Error boundaries and error layout

  • ErrorBoundary and ErrorLayout in app/root.tsx render full HTML documents. In SPA mode they can remain as in-app error UI (no full HTML document unless we explicitly want a minimal “error page” HTML for hard failures).

Out of scope (unchanged)

  • API routes and server: All Hono API routes, auth middleware, session, and observability stay on the server.
  • Cookie/session/CSRF on first request: Server continues to run root-level cookie and session logic for the initial HTML response; only the React route layer (route loaders, full tree render) becomes client-only.
  • Existing client-side data fetching: TanStack Query, URQL, and other client fetches remain; they absorb what route loaders currently do.

Acceptance criteria

  • Only the initial document request runs server-side logic; no server-side route loaders or full React tree render on navigation.
  • Root-level server logic kept: cookies (toast session, CSRF), env.public, and any required session handling still run on the server for the first request; response sets cookies and embeds bootstrap data (ENV, CSRF, toast) in the HTML.
  • TTFB for the initial document is improved (measure before/after); subsequent navigations are client-only.
  • All route loaders are replaced by client-side data fetching or API calls; no server-side route loaders.
  • All route actions are replaced by client-driven API calls (server keeps API routes and cookie/session handling as today).
  • Document title and critical meta tags are set client-side where needed.
  • Auth and redirect behaviour (e.g. unauthenticated → login) preserved (e.g. via bootstrap data or a small session/me API + client redirect).
  • CSP and nonce (if used) still work for the initial document; approach documented.
  • E2E and critical path tests updated and passing.

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions