I spent a while wondering why a site of mine had almost nothing indexed in Bing. The structured data looked perfect. It validated. It showed up in DevTools.
It was never in the HTML.
Here's the one-liner that told me:
curl -s https://example.com/ | grep -c 'application/ld+json'
I got 0. On every page.
The cause
The JSON-LD was rendered with next/script:
import Script from 'next/script'
export default function Layout({ children }) {
return (
<body>
<Script
id="website-structured-data"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
/>
{children}
</body>
)
}
This looks completely reasonable. It's also wrong for this use case.
next/script defaults to strategy="afterInteractive", which means the tag is injected after hydration, on the client. That's the right behavior for analytics snippets and third-party widgets — it's what the component is for. But structured data isn't a script to execute. It's markup that needs to be in the document when a crawler reads it.
So the server-rendered HTML went out with zero JSON-LD, and the tag only appeared once React had booted in a real browser.
Why it's so easy to miss
Every way you'd naturally check it says everything is fine:
- DevTools → Elements shows the tag. Of course it does — that's the live DOM, after hydration.
- Rich Results Test / Schema validators often run a headless browser, so they execute JS and see the tag.
-
Your eyes, because the code reads correctly.
The one place it doesn't show up is
view-source:— the actual bytes the server sent. Which is exactly what most crawlers parse. Google can render JavaScript, so this isn't guaranteed to be fatal there. But rendering is a separate, deferred, budgeted pass — not the same as being in the initial HTML. And plenty of other consumers don't render JS at all, or do it inconsistently: Bing, social card scrapers, and a growing pile of LLM crawlers. If your structured data only exists post-hydration, you're betting your rich results on the most expensive code path. ## The fix Use a plain<script>tag. That's it.
export function JsonLd({ data }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}
No import, no strategy, no id needed. React renders it straight into the server HTML.
The part that surprised me: this works in client components too. I assumed 'use client' meant "not in the SSR output," but client components are still server-rendered on the first pass — they just also hydrate. A plain <script> tag inside one lands in the HTML exactly like it would in a server component.
Verifying the fix
Same one-liner, run against a production build (next build && next start) rather than the dev server:
curl -s http://localhost:3000/ | grep -c 'application/ld+json'
For a whole site at once, against your build output:
find .next/server/app -name '*.html' -exec sh -c \
'echo "$(grep -c "application/ld+json" "$1") $1"' _ {} \;
Mine went from 0 on every page to 2–5 per page. Then I parsed each block to make sure I hadn't shipped malformed JSON along the way:
const html = fs.readFileSync(file, 'utf8')
for (const m of html.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/gs)) {
JSON.parse(m[1]) // throws loudly if you broke something
}
72 blocks, all valid. That check took two minutes and I'd have shipped a busted escape without it.
The general lesson
next/script is for scripts that do something. JSON-LD, and any other metadata-shaped <script>, isn't code — it's content. Content belongs in the HTML.
More broadly: for anything a crawler consumes, verify against curl, not DevTools. The two disagree in exactly the cases that matter, and DevTools is the one that lies to you.
Found this while debugging indexing on how-we-invest.com, a 13F filing tracker I build in Next.js. The root layout was affected, so the bug was quietly live on every page on the site.
Top comments (1)
damn, i didnt even think about checking how the JSON-LD renders in the actual source vs the dev tools. ngl this is a lifesaver