Skip to content

Hydrate server-rendered pages; allow the client-IP header to be configured - #32

Merged
andreogle merged 1 commit into
mainfrom
hydrate-ssr-pages-and-cloudflare-client-ip
Jul 28, 2026
Merged

Hydrate server-rendered pages; allow the client-IP header to be configured#32
andreogle merged 1 commit into
mainfrom
hydrate-ssr-pages-and-cloudflare-client-ip

Conversation

@andreogle

Copy link
Copy Markdown
Owner

Two fixes to things the template already ships but doesn't quite deliver.


1. Server-rendered markup was being thrown away

SSR works — the Node pool renders real markup and the browser paints it. But app.tsx mounted with createRoot, which clears the container and builds the tree again, discarding what the server produced.

I measured it rather than inferring it: a MutationObserver installed ahead of any page script captures a server-rendered element as the parser creates it, then checks whether that same node is still in the document after React mounts.

/ /login /register
before kept=false kept=false kept=false
after kept=true kept=true kept=true

…with hydrationErrors=0 on all three after the change.

Why it isn't just hydrateRoot everywhere

That was my first attempt, and it looked correct — it passed a console.error check. It wasn't. React 19 reports hydration failures through reportError(), not console.error. Listening for window error events instead showed every client-only page failing the match:

Hydration failed because the server rendered HTML didn't match the client.
As a result this tree will be regenerated on the client.

That's inherent to the design: pages/client/ are deliberately excluded from the SSR bundle and render as a server-side no-op, so there is nothing to adopt. Hydrating them fails on every load and pushes React through its recovery path to reach the result createRoot reaches directly.

So the mount follows how the page was produced. generate-ssr-pages.js now emits serverRenderedPages next to the existing client-only set — the strategy directory decides it, with no second list to keep in step.

Two supporting changes

Both are required for the client's first render to match the server's:

  • ssr.tsx renders <Toaster />. Server-side it emits only an empty Radix viewport, but anything the client renders at the root must exist on both sides or it's a mismatch.
  • The initial flash moved into an effect. Toasts live in a store outside React, so queueing one before the first render put a toast in the client tree the server never had. A module-level guard stops StrictMode's double-invoked effects raising it twice.

2. The client-IP header couldn't actually be configured

ClientIp reads :client_ip_headers, and both its moduledoc and the README tell Cloudflare deployments to use cf-connecting-ip — but nothing ever set it, so following that advice meant hand-editing config.

CLIENT_IP_HEADERS now wires it from the environment.

This matters more than it looks. Cloudflare's edge terminates on a public address, and RemoteIp only skips private and reserved ranges automatically — so the default x-forwarded-for walk stops on the edge and reports it as the caller. Every visitor arriving through the same edge shares one rate-limit bucket: better than the single global bucket TRUST_PROXY_HEADERS fixes, and still not per-caller. cf-connecting-ip holds the address Cloudflare already resolved, so there is no chain to walk.

Set-but-empty falls back to the default rather than parsing to [] — an empty header list is how the plug switches itself off, so honouring it would silently disable client-IP resolution instead of configuring it.

The README also now states the direct-to-origin caveat plainly: whichever header is trusted, a request that bypasses the proxy can forge it and pick its own bucket. Closing that means restricting origin access to the proxy. The exposure is limited to rate limiting rather than authorization, but it's worth deciding deliberately.


mix precommit passes (222 tests). The generated page registries are gitignored, so only the generator changes here.

…gured

Two fixes to things the template already ships but doesn't quite deliver.

## Server-rendered markup was being thrown away

`app.tsx` mounted with `createRoot`, which clears the container and builds
the tree again — discarding markup the SSR bundle produced and the browser
had already parsed and painted.

Measured with a MutationObserver installed ahead of any page script,
capturing a server-rendered element and checking whether that node survives
React mounting:

  before   / (ssr)        kept=false
           /login (ssr)   kept=false
           /register(ssr) kept=false

  after    / (ssr)        kept=true   hydrationErrors=0
           /login (ssr)   kept=true   hydrationErrors=0
           /register(ssr) kept=true   hydrationErrors=0

The mount now follows how the page was produced. `pages/ssr/` pages
hydrate; `pages/client/` pages, which are deliberately absent from the SSR
bundle and render as a server-side no-op, keep using `createRoot` —
hydrating those fails the match on every load and pushes React through its
recovery path to reach the result `createRoot` reaches directly.

`generate-ssr-pages.js` emits `serverRenderedPages` alongside the existing
client-only set, so the strategy directory decides the mount and there is
no second list to keep in step.

Two supporting changes, both required for the client's first render to
match the server's:

  * `ssr.tsx` renders `<Toaster />`. Server-side it emits only an empty
    Radix viewport, but anything the client renders at the root has to
    exist on both sides.
  * The initial flash is raised from an effect rather than before render.
    Toasts live in a store outside React, so queueing one pre-render put a
    toast in the client tree the server never had. A module-level guard
    keeps StrictMode's double-invoked effects from raising it twice.

## The client-IP header couldn't actually be configured

`ClientIp` reads `:client_ip_headers`, and both its moduledoc and the
README tell Cloudflare deployments to use `cf-connecting-ip` — but nothing
set it, so following that advice meant editing config by hand.

`CLIENT_IP_HEADERS` now wires it from the environment. This matters
because Cloudflare's edge terminates on a *public* address: the default
`x-forwarded-for` walk stops there and reports the edge as the caller,
since only private and reserved ranges are skipped automatically. Every
visitor arriving through the same edge then shares one rate-limit bucket —
better than the single global bucket `TRUST_PROXY_HEADERS` fixes, and
still not per-caller.

Set-but-empty falls back to the default rather than parsing to `[]`, since
an empty header list is how the plug switches itself off; honouring it
would silently disable client-IP resolution instead of configuring it.

The README also now names the direct-to-origin caveat: whichever header is
trusted, a request that bypasses the proxy can forge it and choose its own
bucket. That is worth deciding deliberately rather than by default.
@andreogle
andreogle merged commit a1ef124 into main Jul 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant