DEV Community

saikodev
saikodev

Posted on

Your Next.js JSON-LD might be invisible to crawlers. Here's a one-line check.

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'
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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) }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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"' _ {} \;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 (3)

Collapse
 
mike_viewfy profile image
Mike Viewfy

The find over .next/server/app only sees routes that were prerendered at build. Anything dynamic (cookies(), searchParams, force-dynamic) never writes an .html file, so it contributes nothing to that walk and reads as passing. I'd build the URL list from your sitemap and curl the running server instead, so the 72 blocks also cover pages rendered per request. We ship this class of fix as PRs in Viewfy, and dynamic routes are usually where the second zero hides.

Collapse
 
alexshev profile image
Alex Shev

This is especially relevant for local SEO pages. A LocalBusiness or Service schema that only appears after client hydration can look fine in a browser and still be useless for a crawler check. I like testing the raw HTML, rendered DOM, and Rich Results-style output separately so the team knows which layer is actually carrying the entity data.

Collapse
 
citedy profile image
Dmitry Sergeev

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