Why is my JSON-LD missing from the HTML in Next.js?
In short
Next.js does not emit a script element for a component using next/script with strategy beforeInteractive. It serialises the payload into a self.__next_s array that the client bundle reads after hydration, so JSON-LD declared that way is absent from the served HTML and invisible to any crawler that does not execute JavaScript.

- Symptom
- JSON-LD present in DevTools, absent from view-source and curl
- Console output
- None — nothing errors
- Cause
- next/script strategy="beforeInteractive" emits no element
- What is emitted instead
- A self.__next_s array entry, read after hydration
- Blast radius
- Every route rendered by the layout that declares it
- Fix
- A plain <script type="application/ld+json">
An external audit told me the identity block on my homepage had no name and no description. That was true, and it was the least interesting thing wrong. The reason a page carrying four structured-data nodes was being judged on one of them is that the other three were not in the HTML at all.
I work in digital marketing. Structured data is not an exotic corner of my job, it is close to the centre of it — which is why finding this on my own site was an unpleasant fifteen minutes. Everything looked correct in every tool I habitually check. It had been wrong for months.
#The code that looks completely fine
My root layout declared the site's Person and WebSite nodes like this. If you have ever added JSON-LD to a Next.js app, there is a reasonable chance you have written something close to it.
import Script from "next/script";
<Script
id="ld-person"
type="application/ld+json"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
/>The reasoning behind beforeInteractive seems sound when you write it: this is metadata, crawlers should see it as early as possible, so load it as early as the framework allows. Every part of that sentence is wrong, but not obviously.
#What beforeInteractive actually does
next/script with strategy="beforeInteractive" does not put a script element in the document. It serialises the props into an array on self.__next_s, and the client-side bundle walks that array after it loads and creates the real element then.
So the served HTML contains something like this, which is a string of escaped JSON inside a bootstrap call rather than a block any parser is looking for:
<script>(self.__next_s=self.__next_s||[]).push([0,{
"type":"application/ld+json",
"children":"{\"@context\":\"https://schema.org\",\"@type\":\"Person\"…"
}])</script>#The check that tells the truth
Ask the server, not the browser. If the count comes back lower than the number of nodes you believe you have, the missing ones are being injected by JavaScript.
curl -s https://example.com/ | grep -c 'type="application/ld+json"'On my site that returned one. The DOM had four. The one it found was the only node I had written as an ordinary script tag inside a page component — which is why the audit judged my entire identity on it, and why the fields it wanted were missing: they were all sitting in the Person node it could not see.
#Who actually loses
It is worth being precise about the damage rather than overstating it. Google renders JavaScript in a second pass, so it can eventually pick up JSON-LD injected this way — deferred, and dependent on a render budget you do not control.
Almost nothing else does. Link unfurlers, most answer-engine fetchers, and any straightforward HTTP client see the response and nothing more. If your structured data exists only after hydration, then for that entire population it does not exist. For a personal site whose whole purpose is telling machines who the subject is, that is the failure that matters.
#The fix is to stop using next/script
JSON-LD is inert data. Nothing reads it at runtime, nothing depends on when it arrives, and there is therefore no reason for it to be loaded, ordered or prioritised at all. It only needs to be present.
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
/>It can sit in the body — JSON-LD is valid anywhere in the document, and React 19 hoists bare <link> elements into the head but not inline scripts, so do not expect it to move. After the change the same curl returned four blocks, and the audit score moved from 79 to 83 on the next run.
The score is the least important part of that. What actually changed is that the site's identity — the job title, the corroborating profile links, the disambiguation from somebody else with my name — is now readable by something that does not run JavaScript.
#The habit worth taking from this
The general form of this bug is verifying what was rendered instead of what was served, and it is not specific to structured data or to Next.js. Anything a framework describes as an optimisation is worth checking in the response body at least once, because optimisations are exactly the features that change where output ends up.
- Check structured data with curl against the deployed URL, never with DevTools.
- Count what you expect. A number that is lower than your node count is the whole diagnosis.
- Treat inert metadata as content to be printed, not as a script to be scheduled.
For what it is worth, the audit that surfaced this is Is Agentic, built by Vercel on Ora's checks. I would not have found this on my own — I had already looked at that layout many times and seen nothing wrong, because I was looking in the panel that shows you what you meant rather than the one that shows you what you shipped.
Questions this answers
Does next/script with beforeInteractive work for JSON-LD?
No. That strategy does not emit a script element into the server-rendered HTML; it serialises the payload into a self.__next_s array which the client bundle turns into a real element after hydration. Structured data declared this way is missing from the response and visible only to clients that execute JavaScript.
How do I check whether my structured data is in the served HTML?
Run curl against the deployed URL and count the literal occurrences of type="application/ld+json" in the output. Compare that number to the nodes you expect. Browser DevTools shows the live DOM after JavaScript has run, so it will display JSON-LD that was never present in the response.
Can Google read JSON-LD that is added by JavaScript?
Usually, because Google renders pages in a second pass and can pick up structured data injected during it. That pass is deferred and subject to a render budget. Most other consumers — link unfurlers, answer-engine fetchers and plain HTTP clients — do not execute JavaScript and will never see it.



