Engineering6 min read

Everything was green and nothing worked

Four failures that reported success: a revalidation that changed nothing, a 404 served as 200, schema for content that was not there, and a tag silently dropped.

The failures that cost the most are not the loud ones. A stack trace is a gift: something is wrong, here is where, go and look. The expensive ones return 200, log nothing, and go on reporting success for weeks while quietly doing nothing.

Four of those turned up on one Next.js site in a single week. They are unrelated in mechanism and identical in shape: the success signal was real, and the outcome was not. Each one is worth knowing on its own, and the pattern behind them is worth more.

1. The revalidation that rebuilt the page and changed nothing

Content lives in a headless CMS. Pages are statically generated and every CMS request is cached for an hour. An endpoint calls revalidatePath so an editor does not have to wait out that hour.

Copy was edited. The endpoint was called. It returned 200. The CDN header said x-vercel-cache: REVALIDATED, so the page had genuinely regenerated. And the old text was still on screen.

Everything reported success because everything had succeeded. revalidatePath invalidates the rendered route. It does not invalidate the fetch responses that route is built from. The page rebuilt, asked for the CMS payload, was handed the cached one, and rendered exactly what it rendered before.

The fix is to invalidate the data as well as the render:

// The fetch has to be tagged for anything to be able to drop it
const res = await fetch(endpoint, {
  next: { revalidate: 3600, tags: ['cms'] },
});

// And the tag goes first: clearing the render alone rebuilds
// the page from the same stale payload
revalidateTag('cms');
revalidatePath('/', 'layout');

What made this one expensive was not the missing line. It was that the failure mode looked exactly like a different bug. The obvious read is "the CMS write did not save", so the natural response is to go and save it again. That is a loop that can absorb an afternoon.

2. The 404 that returned 200

Middleware rewrote incoming paths to a locale prefix, with a matcher excluding things that should be served as-is:

matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt).*)']

An exclusion list of specific filenames. Fine until a new file lands in public/ that nobody thinks to add: a verification key, ads.txt, security.txt. That file gets rewritten, matches no route, and falls through to the catch-all not-found page.

Which is rendered. Successfully. With a status of 200.

So the URL "worked". curl -o /dev/null -w '%{http_code}' said 200. The browser showed a page. The only thing wrong was that the response body was HTML where a service expected 32 bytes of plain text.

That service was IndexNow, which uses a key file to verify you own the domain it is being asked to index. It accepted the submissions, went to check the key, received a web page, and blocked the domain. The submissions were discarded. The error surfaced days later as a 403 with no obvious connection to the cause.

Two things to take from it. The matcher should exclude by shape, not by name:

matcher: ['/((?!api|_next/static|_next/image|.*\\.txt$|.*\\.xml$).*)']

And more generally: for a file, a 200 proves nothing. The check has to look at the body.

# Says nothing useful
curl -o /dev/null -w '%{http_code}' https://example.com/key.txt

# Says something useful
curl -s https://example.com/key.txt | diff - expected-key.txt

3. Structured data describing content that was not on the page

Eight service pages carried FAQPage JSON-LD, four or five questions each, correctly formed. Google's Rich Results Test passed them.

None of those questions appeared anywhere in the page body. The markup had been added in one commit and the rendering never followed. What visitors saw was a different, site-wide FAQ.

Validators check that markup is well-formed. They do not check that it is true. Google's guidelines require FAQ content to be visible to the user, so this was invalid markup on eight pages, passing every automated check available.

Catching it takes stripping the scripts and looking at what is left:

curl -s https://example.com/page \
  | perl -0pe 's/<script.*?<\/script>//gs' \
  | perl -0pe 's/<[^>]+>/ /g' \
  | grep -c "What frontend frameworks"

Zero, on every page.

The durable fix was structural rather than textual. The questions now live in one array; a component renders them and emits the JSON-LD from that same array. Two outputs, one source. They cannot disagree, because there is nothing left to disagree with.

4. Metadata that a child route silently replaced

Next.js merges route metadata from the root layout down. The merge is per field, and a child that sets a field replaces the parent's value for it rather than extending it.

A feed link was declared once, in the root layout:

// Root layout
alternates: {
  types: { 'application/rss+xml': `${BASE}/blog/rss.xml` },
}

Every page also sets its own canonical:

// Every page
alternates: { canonical: url }

Each page's alternates replaced the layout's. The feed link reached the head on exactly zero pages. Nothing errored, nothing warned, and the tag was simply absent. It was only noticed because the verification looked for the tag in the output rather than in the source.

Declaring it as an element instead sidesteps the merge:

<link
  rel="alternate"
  type="application/rss+xml"
  href={`${BASE}/blog/rss.xml`}
/>

The thing these have in common

None of these were caused by careless work. Every one of them was somebody doing the documented thing:

  • calling the documented revalidation API
  • excluding files from middleware, as the docs show
  • adding schema, validated with the official tool
  • setting metadata in the root layout for the whole site
Four rows comparing what was reported with what happened. revalidatePath returned 200 and the CDN said REVALIDATED, but the page rebuilt from the same cached payload. A key file returned 200, but the body was the 404 page and IndexNow blocked the domain. FAQ markup passed the Rich Results Test, but the questions were on no page. Metadata compiled without a warning, but every child route replaced it and the tag reached zero pages.
Every signal on the left is true. None of them says anything about the outcome on the right.

Each failed because the verification checked that the operation completed rather than that the outcome happened. The endpoint returned 200. The URL returned 200. The markup validated. The metadata compiled. All true, all irrelevant.

The check that would have caught all four is the same one:

Fetch the live thing. Look at the content. Compare it to what you intended.

That principle is boring enough to skip and cheap enough to automate. On this site it turned into a script that fetches production and asserts the specific strings are in the served HTML, that the text files are text and not markup, that every declared FAQ question appears in the body, and that the feed tag is in the head. Sixty-five assertions, a few seconds, gating the deploy.

It has already caught one thing nobody would have looked for otherwise: a check that matched /\bus\b/i was flagging "the UK, US and Australia" as first-person writing. The verification had a bug of exactly the kind it was written to find.

If you take one thing

Grep your own production output for the strings you believe are there. Not your source, not your staging, not your CI logs. What the server actually sent.

The gap between the two is where these live.