-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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, readsenv.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.serverfor that one request only).
- Runs root-level logic:
-
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 withuseQuery/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.tsx—action→ call logout API from client.app/routes/org/detail/projects/index.tsx—action→ API for alert/close or project mutation.app/routes/account/organizations/index.tsx—action→ 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.tsin the bootstrap pass; embedenv.publicin 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.tsxusesurqlStatefrommatches(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
ErrorBoundaryandErrorLayoutinapp/root.tsxrender 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
Labels
Type
Projects
Status