All case studies
2 min read

Why my SSR site was secretly client-rendering

An Angular SSR app behind Dokku's nginx silently fell back to client-side rendering. The fix was one line — finding it took understanding how the framework validates the request host.

AngularSSRnginxDokku

The problem

The site looked perfect in a browser. But when I curled it, the homepage was ~5KB — a near-empty shell that only filled in once JavaScript ran. For a site whose entire point is SEO, that's a quiet disaster: crawlers that don't execute JS (and the ones that do, on a budget) were seeing nothing.

So it rendered on the server in dev, but in production behind nginx it was falling back to client-side rendering. Same build, different behaviour.

The architecture

The relevant path is: Cloudflare → Dokku's nginx → the Node SSR process. nginx terminates the connection and forwards the request with X-Forwarded-* headers describing the original host and protocol.

Modern SSR frameworks validate the request host before rendering — partly as an SSRF/cache-poisoning guard. If they can't trust who the request was really for, some will refuse to fully render and hand the work to the client instead.

The code that mattered

The browser told me nothing. The server logs told me everything:

Received "x-forwarded-host" header but "trustProxyHeaders" was not set up to allow it.

The framework was receiving nginx's forwarded host but, by default, declining to trust it — so it couldn't confirm the host matched its allowlist, and bailed to CSR. The fix is to opt into trusting the proxy, since I control it:

const angularApp = new AngularNodeAppEngine({
  // Behind Dokku's nginx — trust X-Forwarded-* so SSR sees the real host.
  trustProxyHeaders: true,
  allowedHosts: ["tishandavid.com", "www.tishandavid.com"],
});

That's the whole fix. One property.

Results

  • The homepage went from a 5KB shell to ~75KB of fully server-rendered HTML.
  • All the structured data and copy that lives in the page is now in the initial response, where crawlers can see it.
  • I verified it the same way I found it — with curl, not the browser:
curl -s https://example.com/ | grep -c "ng-server-context"
# 1  → server-rendered

What I'd do differently

I'd have checked the byte size of the server response on day one, as a deploy smoke test. A page that renders fine in your browser can still be invisible to crawlers, and the only reliable signal is the raw HTML — view-source, not DevTools.

The deeper lesson: when something works locally but not in production, the difference is almost always the layer you added in between. Read that layer's logs first. I burned an hour in the browser before I read the one line the server was shouting at me.