DEV Community

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' 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 ( {children} ) } 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 tag. That's it. export function JsonLd({ data }) { return ( ) } 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 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(/ (.?) /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 , 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 (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.