The Definitive Guide to adding JSON-LD Schema in Next.js (App Router)


What is JSON-LD and Why Should You Care?
JSON-LD stands for JavaScript Object Notation for Linked Data. It’s a way to describe your website’s content so that search engines understand not just what’s on the page but how everything is connected.
It uses regular JSON format to embed structured data in your HTML via a <script type="application/ld+json">
tag. This script isn’t visible to users but helps Google identify key information like authors, recipes, events, products, etc.
Why does this matter? Because it enables rich results, which are enhanced search listings with star ratings, images, FAQs, and more. These rich results can significantly boost your site's performance:
Rotten Tomatoes saw a 25% higher CTR with structured data.
Food Network got 35% more visits after implementation.
Nestlé reported 82% higher CTR from rich result pages.
In short: JSON-LD helps your content stand out in search, improves click-through rates, and gives users more reason to engage with your site.
Read More:
LD-JSON in Next.js App Router – The Problem
Next.js provides an official guide on adding JSON-LD schema using the App Router:
https://nextjs.org/docs/app/guides/json-ld
However, there's a subtle issue.
When you add the <script type="application/ld+json">
tag directly in a server component, it gets rendered twice:
Once on the server – as part of the initial HTML.
Again on the client – when React hydrates and re-renders the component.
This results in duplicate JSON-LD tags, which can confuse schema detectors like Google’s Structured Data Testing Tool. It may flag the duplication or even ignore the markup altogether.
This behavior happens because server components embed RSC (React Server Component) data in the HTML, and then React re-renders on the client side, causing your schema block to be injected again.
This is an issue that is known to occur sometimes with Next.js.
What’s the fix then?
The solution is to create a custom client component that checks if the schema tag is already present in the DOM before rendering it again.
The Solution: useSkipHydration
Hook + SchemaRenderer
Component
// components/SchemaRenderer.tsx
"use client";
import { useEffect, useState } from "react";
const useSkipHydration = (id: string) => {
const [skipHydration, setSkipHydration] = useState(false);
useEffect(() => {
if (document?.getElementById(id)) {
setSkipHydration(true);
}
}, [id]);
return skipHydration;
};
export default function SchemaRenderer({
id,
schema,
}: {
id: string;
schema: any;
}) {
const skipHydration = useSkipHydration(id);
if (skipHydration) {
return null;
}
return (
<script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
- Use it in your page (Server Component):
// app/[slug]/page.tsx
import SchemaRenderer from '@/components/SchemaRenderer';
const jsonLdData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "Your Blog Title",
"author": {
"@type": "Person",
"name": "John Doe"
},
"datePublished": "2025-06-01"
};
export default function BlogPage() {
return (
<>
<article>
<h1>Your Blog Content</h1>
</article>
<SchemaRenderer id="json-ld-blog" schema={jsonLdData} />
</>
);
}
Validating Your Structured Data
How This Fix Actually Works – Under the Hood
The core idea behind this fix is simple but powerful: detect whether the JSON-LD <script>
already exists in the DOM during hydration, and if it does, don’t render it again. Let’s break down how each part works:
useSkipHydration
Hook
const useSkipHydration = (id: string) => {
const [skipHydration, setSkipHydration] = useState(false);
useEffect(() => {
if (document?.getElementById(id)) {
setSkipHydration(true);
}
}, [id]);
return skipHydration;
};
This custom hook checks after the component mounts on the client if a script tag with a specific
id
already exists in the DOM.This is crucial because:
The server render already injected the
<script>
tag into the HTML with a givenid
.During hydration, React tries to re-render it again on the client.
If we detect that the script already exists (via
document.getElementById(id)
), we skip rendering it again.
Conditional Rendering Based on Presence
if (skipHydration) return null;
If the hook detects that the script is already present, it simply returns
null
, meaning React won't re-render or add the tag again.This effectively avoids duplication of the
<script type="application/ld+json">
.
Rendering the Schema Only Once
<script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
If
skipHydration
isfalse
(meaning the tag wasn’t found), we render the JSON-LD<script>
tag.The
dangerouslySetInnerHTML
is used to inject the raw JSON directly into the script—required for JSON-LD to work.
Final Thoughts: Structured Data Done Right in Next.js
Implementing JSON-LD is one of the simplest yet most effective ways to enhance your site’s visibility in search, unlocking rich results, improving click-through rates, and helping search engines deeply understand your content.
But in the world of React Server Components and hydration quirks, doing it naively can cause real problems, like duplicated tags and failed validation.
Using this approach has the following benefits:
Plays nicely with SSR and hydration
Avoids duplication of
<script>
tagsKeeps your schema clean and Google-friendly
Whether you’re working on a blog, product pages, or a complex Next.js app, this setup ensures your structured data is robust, scalable, and SEO-optimized.
Subscribe to my newsletter
Read articles from Chaitanya Raj directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Chaitanya Raj
Chaitanya Raj
I'm a fullstack web developer from New Delhi and a CS alumni of the University of Delhi. I have worked with React (NextJS) / Vue (NuxtJS) for front-end dev, Node.js for back-end, Webflow for no-code dev and Figma for web design. I have a wide variety of interests, ranging from Web Dev, Design, CyberSecurity and AI/ML to History, Geopolitics, Psychology, Linguistics and Art. I love to learn new things. Curiosity is the prime motivator for me. If I find something that catches my eye, well, I'll be in my room tinkering with it for the next few weeks. I write sometimes.