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

Chaitanya RajChaitanya Raj
4 min read

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:

  1. Once on the server – as part of the initial HTML.

  2. 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) }}
    />
  );
}
  1. 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:

  1. 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 given id.

    • 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.

  1. 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">.

  1. Rendering the Schema Only Once

<script
  id={id}
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
  • If skipHydration is false (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> tags

  • Keeps 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.

0
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.