TL;DR (aka future GT, read this first next time): An SEO audit told me my category pages had zero metadata — no title, no canonical, no Open Graph tags. I assumed the route was missing its generateMetadata() function. It wasn't. Sixteen turns and six wrong hypotheses later, the real bug surfaced: a null where I assumed undefined. TypeScript's = [] default doesn't catch null. The fix was three characters: ?? [].
The audit said "missing metadata." Easy, right?
I built IndianCoffeeBeans.com, I run it, and for months my /learn/category pages had been shipping with zero SEO tags.
An audit flagged seven issues on the category route: missing title, missing canonical, missing meta description, missing og:title, og:description, og:image. The entire SEO profile, gone.
My first thought was the obvious one: the route must be missing its generateMetadata() function. That's the usual culprit. I checked.
It was there. Committed since February. Logically identical to the article route, which worked fine.
So began the hypothesis parade.
Six wrong hypotheses
- "
generateMetadata()is missing." Nope — there since February, identical to the working route. - "Image processing is throwing." No categories had images set, so
urlFor()never even ran. - "It's a dynamic-rendering issue." The article route is dynamic too, and it worked.
- "Data-shape mismatch." The GROQ queries and Sanity schemas were clean.
- "Stale deploy." Production was running June code; the February fix was definitely there.
- "Streaming/metadata boundary issue." The page body rendered perfectly. Only the metadata was missing.
Six hypotheses. All wrong. Sixteen turns of back-and-forth with Claude, going in circles.
Then it reproduced the bug locally, and the console printed exactly one line:
TypeError: keywords is not iterable
The bug was null, and I'd trusted a type system that didn't care
Here's the code, stripped down to the part that mattered:
function generateSEOMetadata(metadata: { keywords?: string[] | null }) {
const { keywords = [] } = metadata // ← only catches undefined
mergeKeywords(keywords, defaults.keywords) // ← [...null] throws
}
In Sanity, metadata.keywords was null — the field had never been set on any category. Not undefined. null.
TypeScript's destructuring default (= []) only fires on undefined. null sails straight through. Then [...null] throws, generateMetadata crashes, and here's the part that still annoys me: when generateMetadata throws, Next.js doesn't gracefully fall back to your layout defaults. It returns nothing.
Zero metadata tags. No title. No canonical. No OG. My category pages had the SEO profile of a blank document — for months — and the only thing wrong was a null in a field nobody had ever set.
The fix, for the record:
keywords: category.metadata?.keywords ?? [],
Three characters: ?? .
What it actually taught me
The bug wasn't complicated. That's the point. The code type-checked, linted, and built clean. Every hypothesis failed because the real bug was invisible to static analysis — it lived in the gap between "the type says optional" and "the runtime says null."
And the bigger lesson has nothing to do with TypeScript:
Every boundary where data crosses into your code is a null waiting to happen. CMS → code. API → code. Database → code. The ? on a type is not a safety net — it's a suggestion. null and undefined are different, and the moment you assume they're interchangeable is the moment you ship something that won't crash loudly. It'll just quietly erase a whole route's SEO.
That's what "data integrity" actually is. Not enterprise jargon — me at 2 AM discovering that the difference between = [] and ?? [] was the difference between a page that exists and a page that exists invisibly.
Oh, and I did the dumb version of the lesson too: I spent sixteen turns debugging remotely when a single npm run dev and a browser hit would have surfaced that console error in thirty seconds. Local reproduction first, theories second. I know this. I did it backwards anyway.
The checklist I should've followed
- If the data comes from a CMS or database, type it
field: T | null— notfield?: T. - Nullish-coalesce at the boundary:
?? [], not= []. - Test with a record that has
nullfor every optional field. - Reproduce locally before shipping a single theory.
What's the dumbest bug you've shipped that passed every check — the one where the type system said you were safe and the runtime disagreed?