JSON-LD in Next.js: 4 Ways Tested, Only 2 in the HTML
To add JSON-LD in the Next.js App Router, render a script tag from a server component, and escape < in the JSON:
// app/blog/[slug]/page.tsx
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const post = await getPost((await params).slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
datePublished: post.published,
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
<h1>{post.title}</h1>
</article>
);
}That's the pattern in the Next.js docs. I tested it against the other ways people add structured data, on a fresh Next.js 16.4.4 app, and checked the raw HTML each one returned.
What each method puts in the HTML
script tag in a server component in the HTML
script tag in a "use client" component in the HTML
next/script with dangerouslySetInnerHTML not in the HTML, added after JavaScript runs
useEffect that appends a script tag not in the HTML, added after JavaScript runsWith next/script, the JSON only existed inside Next.js's own data payload. In Chrome it showed up in the body once the page had loaded. The useEffect version appeared in the head, also only after JavaScript ran.
A client component is fine here, because client components still render on the server first. The problem is code that only runs in the browser.
Does JavaScript-added JSON-LD still count?
For Google, mostly yes. Google says Search "can understand and process structured data that's available in the DOM when it renders the page". For product pages it warns that dynamically generated markup "can make Shopping crawls less frequent and less reliable".
Other crawlers aren't Google. Many tools and crawlers read the HTML and never run your JavaScript, so markup that only appears after hydration doesn't exist for them. A script tag rendered on the server costs nothing extra, so there's no reason to take the risk.
Escape < or the page breaks
JSON.stringify doesn't escape </script>. I put a headline containing a closing script tag into the JSON without the replace, and the raw HTML came out like this:
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Article","headline":"Why </script><script>alert(1)</script>The browser ends the JSON-LD block at the first </script> and runs what follows as a new script. With data from a CMS or from users, that's a cross-site scripting hole, and even harmless text breaks the structured data. .replace(/</g, "\\u003c") turns every < into an escape that JSON reads as the same character.
Where to put it
Rendering the script tag inside the page puts it in the body. That's fine: JSON-LD doesn't need to be in the head. Put site-wide data, like your Organization or Person, in the root layout, and page data, like BlogPosting or Product, in the page that has the values.
Keep the values the same as what's visible on the page. If the price, date or headline in your JSON-LD differs from the page, Google may ignore it.
Check it
curl -s https://example.com/blog/my-post | grep -o '<script type="application/ld+json">[^<]*'If that prints your JSON, it's in the HTML. Then run the URL, not pasted code, through Google's Rich Results Test to see which rich results the page qualifies for.