200 from curl, 500 in Chrome: the JSON-LD bug that lived in source order

Our landing page passed every server check and crashed on hydration. The bug was the position of one line, and only the browser could see it.

June 6, 2026

Here is a debugging situation that feels impossible the first time you meet it. curl https://boiledplate.ai/ returns a clean 200 with the full HTML, the SoftwareApplication JSON-LD, the FAQPage schema, everything. Open the same URL in Chrome and you get Nuxt's red client-side 500 page a beat after the first paint. Then, as a bonus, a second exception fires every time you click away from the page.

Same code. Same URL. Same request. The server says fine, the browser says dead. We spent a while staring at that before the shape of it clicked, so here is the whole thing: what broke, why the two runtimes disagreed, and the change that fixed it (which was moving code, not writing any).

The page in question

app/pages/index.vue is our landing page. Near the top of <script setup> it builds a pile of localized data with computed(): the feature grid, the pricing "what's included" list, the three how-it-works steps, and the FAQ. The FAQ one looks like this:

const faqItems = computed(() => ([
 'included', 'stack', 'delivery', 'agent', 'whichAgents',
 'updates', 'oneTime', 'license', 'refund',
] as const).map(key => ({
 label: t(`landing.faq.items.${key}.q`),
 content: t(`landing.faq.items.${key}.a`),
})))

Further down, a useHead call emits two application/ld+json script tags. The SoftwareApplication block carries the offer price. The FAQPage block is built by mapping over faqItems.value, so Google sees the same questions the accordion renders.

In the version that shipped the bug, those two halves were in the wrong order. The useHead block sat above the const faqItems declaration. Schematically:

useHead({ /* ... maps over faqItems.value ... */ }) // line ~80
// ...a bunch of other consts...
const faqItems = computed(/* ... */) // line ~120

A useHead that reads a const declared forty lines later. Read top to bottom, nothing screams. And on the server, nothing does scream: the prerender is green.

Why the browser was the only one that complained

The instinct is to suspect useHead of being broken. It is not. The disagreement comes from when useHead decides to read the object you hand it, and that timing is different on each runtime.

During SSR, head input is resolved lazily. Nuxt collects the entries and serializes them at the end of the render, once the entire setup function has finished running. By the time anything actually reads faqItems, the const was initialized long ago. The reference points at a live computed, the FAQPage JSON serializes, the response is a 200. Source order between the useHead call and the declaration is invisible, because the read happens after both have run.

On the client, useHead resolves its input synchronously, inside a watchEffect that runs as part of setup. That effect fires the instant useHead is called. With the block sitting above the declaration, that instant is before const faqItems has executed.

And that is the trap, because const and let bindings sit in the temporal dead zone from the top of their scope until the exact line that declares them. Touching faqItems inside that window does not give you undefined, the way a hoisted var or function would. It throws ReferenceError: Cannot access 'faqItems' before initialization. Hard stop. The synchronous client effect walked directly into the TDZ, the error propagated through hydration, and Nuxt swapped in its 500 page.

So the portable lesson is not about this file. It is: the client evaluates head input eagerly, at the call site; the server evaluates it lazily, after setup completes. Any source-order dependency between a useHead call and a binding it references is a no-op on the server and a fatal error in the browser.

The second stack trace was a decoy

There was a tell that something deeper was off: a second exception, a TypeError inside entry.dispose(), every time you navigated away.

It falls straight out of the first. When the synchronous effect throws partway through the useHead call, the call never returns, so the head entry object it was meant to produce never gets assigned to its slot. Later, when the component unmounts and unhead iterates its entries to tear them down, it reaches that empty slot and calls .dispose() on nothing.

Two stack traces on one page baits you into hunting two bugs. There was one. The disposal TypeError was a downstream symptom, and it vanished the moment the TDZ error was gone.

The fix was a cut and paste

Move the useHead block below every binding it touches. No logic changed; the diff is just relocating the head config (and the numericPrice computed the SoftwareApplication price uses) so it follows faqItems and friends instead of preceding them.

We also left a comment in the file, and we meant it:

// NOTE: this block must stay BELOW every binding it references (faqItems!).
// useHead resolves its input synchronously on the client, so a reference to
// a binding declared later in setup throws a TDZ error that breaks the page.

That comment is load-bearing. In a normal <script setup>, the order of declarations carries no meaning past hoisting, so the next person tidying the file would cheerfully float the useHead call back up to group it with the other head config and reintroduce the exact crash. The comment exists to stop that.

What we actually took away

A server-only smoke test can pass on a corpse. We prerender the landing page and we could have asserted "200, contains the JSON-LD" all day. It would have stayed green while real browsers ate a 500, because the prerender runs the server path, which is the one path where this bug does not exist. If a route hydrates, something has to hydrate it in a browser-like environment before "tested" means anything.

"Works on the server, breaks on the client" is almost always a timing word, not a logic word. The same expression runs at two different moments relative to the rest of setup. TDZ is one way that bites. Reading window, or a template ref, or anything DOM-shaped before it exists is another. When you see the split, ask when a thing runs before you ask what it does.

const did us a favor. Had faqItems been a var, the client would have read undefined, then .map would have thrown a mushy "cannot read properties of undefined," and we would have chased the symptom instead of the cause. The ReferenceError named the binding and pointed at the ordering. Strictness turned a quiet hazard into a loud, specific one.

The honest part is that this is invisible going in and obvious in hindsight. The setup function reads cleanly top to bottom, and nothing about useHead({ ... }) advertises that it evaluates its argument at the call site on one runtime and after setup on the other. If you like SEO-shaped bugs that only surface once the built output is the thing being served, we wrote up another one: our blog that canonically pointed at localhost.

#nuxt #ssr #hydration #debugging #javascript

Read more