Four Layers of Wrong: Shipping SSR on Vercel

It took four deploys to server-render one page. Three of the four failures returned 200 OK — a successful status is not evidence of a working page.

Edgar Durand· Founder, CodeAgent Mobile··5 min read

We added a blog to a Vite single-page app on Vercel. The content lives in a database and is edited from an admin console, so the pages had to be server-rendered — a static build could only ever be as fresh as the last deploy.

It took four deploys to get right. Not because the code was hard, but because each failure hid the next one, and three of the four produced a 200 OK.

That is the part worth writing down. A 500 is a gift. It tells you where to look. What we shipped instead was a series of pages that loaded successfully and were completely wrong.

Layer 1: the container would not start

The backend module for the blog mounted an auth guard on its admin controller. In NestJS, guards declared on a controller are instantiated inside that module's injector — so the module has to import whatever provides the guard's dependencies. Ours did not.

Cloud Run's report:

ERROR: The user-provided container failed the configured startup probe checks.

That is the whole message. No stack trace in the deploy log, no hint about dependency injection, nothing to search for. The actual error — Nest can't resolve dependencies of the JwtAuthGuard — lives only in the revision's own logs, and you have to already suspect a boot failure to go looking there.

This was the second time this exact failure had shipped in our codebase. So the fix came with a test that asserts the invariant directly: every guard a controller declares has a module backing it. It runs in three seconds and fails in CI instead of after a full image build, push, and probe timeout.

Layer 2: every blog URL served the landing page

With the backend up, the SSR route rendered React at request time and injected the markup into the app shell:

html.replace('<div id="root"></div>', `<div id="root">${appHtml}</div>`)

That replace never matched, because the file we read as the shell was dist/index.html — which our prerender step overwrites with the rendered home page. Its root div was already full of landing markup.

String.replace with no match returns the original string. Silently. So every blog URL returned a complete, valid, 200-OK marketing page.

The fix is to keep a pristine copy of the shell before the prerender overwrites it, and a build-time check that refuses to ship if that copy's root is not empty. But the lesson is about the API: a replace that finds nothing looks identical to a replace that worked. If the substitution is load-bearing, assert on it.

Layer 3: the function was never deployed

Next deploy: /blog returned 200 and served the landing page again. Same symptom, different cause.

The serverless functions were built by our build script into api/*.mjs — and gitignored, because generated artifacts do not belong in version control. But Vercel collects functions from the source tree. A file that only exists after the build is not a function it knows about.

With no function to route to, the catch-all rewrite /(.*) → /index.html took over and served the SPA. Another 200. Another page that looked plausible.

What finally separated this from the previous layer was a response header:

cache-control: public, max-age=0, must-revalidate   ← static file
cache-control: public, max-age=0, s-maxage=60       ← our function

Our function sets a shared-cache directive. The static fallback does not. Once we knew to look at headers rather than status codes, the diagnosis took seconds.

There is a related trap here worth naming. package.json for that app declares "type": "module", which means any .js file in it is parsed as ESM regardless of what your bundler emitted. We had produced a CommonJS bundle; its module.exports assignment ran — we verified with a probe that default was set at that exact line, and still set at the end of the file — and require() from outside returned {}. The same bundle copied to /tmp worked fine. That is what isolated it to the package's type field. Emitting .mjs sidesteps the entire question on both sides.

Layer 4: a 500 that could only happen in production

Finally, an honest error:

ENOENT: no such file or directory, open '/var/task/dist/_ssr-shell.html'

The function read its HTML shell from disk at request time. That works in every local run, because locally the process can see the whole repository. It cannot work in production, because a serverless function's filesystem is its own bundle. /var/task contains the function and its dependencies. It does not contain your site's build output.

This is the one that local testing structurally cannot catch. There is no amount of care in a dev environment that surfaces "the deployed filesystem is a different filesystem." The fix is to remove the runtime dependency entirely — read the shell at build time and inline it into the bundle, so there is no file to be missing.

We also made the build refuse to proceed if the shell is absent or its root is non-empty, which folds layer 2's failure into the same guard.

The false lead in the middle

Between layers three and four we spent real time chasing a bug that did not exist.

We were testing production with plain requests and reading 200s that served the landing page — long after we had deployed a fix. The responses were coming from the CDN: x-vercel-cache: HIT. We were diagnosing a cached copy of an older failure.

Adding a random query parameter to every check changed the picture immediately: /blog/rss.xml was already working and returning proper XML, while /blog was returning a genuine 500. Two different states, and the cache had been showing us one stale answer for both.

If you are debugging a deployment and the symptom does not change after a fix, cache-bust before you change anything else.

What we kept

Four guards, each pinned to one of the four failures:

  • Every guard a controller declares has a module providing its dependencies.
  • The SSR shell exists and its root div is empty.
  • The bundles are .mjs, export a default handler, and contain no unresolved relative imports.
  • The bundle never references the shell path — proof the HTML is baked in rather than read from disk.

None of them are clever. All of them fail in CI in seconds, instead of in production behind a 200.

The thread running through all four: a successful HTTP status is not evidence of a working page. Three of these shipped as 200s, two of them serving a perfectly rendered document that happened to be the wrong document. When you are verifying a deploy, check what the page contains and which layer answered — not whether the request succeeded.

← All posts