# SEO with Next.js App Router: Adding Structured Data for Rich Results

## 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, and more.

Why does this matter?

Because it enables **rich results**, enhanced search listings with star ratings, images, FAQs, and more. These can significantly boost your site's performance:

* **Rotten Tomatoes** added structured data to 100,000 pages and saw a **25% higher click-through rate** on enhanced pages.
    
* **The Food Network** enabled search features on 80% of its pages and observed a **35% increase in visits**.
    
* **Rakuten** found users spent **1.5x more time** and had a **3.6x higher interaction rate** on AMP pages with structured data.
    
* **Nestlé** reported an **82% higher click-through rate** for pages showing rich results.
    

**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:**

* [json-ld.org](https://json-ld.org/)
    
* [Google’s guide to structured data](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data)
    

---

## LD-JSON in Next.js App Router – The Problem

Next.js provides [official guidance](https://nextjs.org/docs/app/guides/json-ld) on using JSON-LD with the App Router, introduced in Next.js 13. However, there’s a subtle but important issue.

When you add a  
`<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 – during hydration, when React re-renders the component.
    

This results in **duplicate JSON-LD tags**, which can confuse tools like Google’s Structured Data Testing Tool. It might flag the duplication, or it might just ignore your markup altogether.

Why does this happen?

Because [React Server Components embed data in the HTML](https://github.com/vercel/next.js/discussions/42170#discussioncomment-8137079), but then React hydrates and re-renders the page on the client, thus injecting your schema again.

This is a [known issue](https://github.com/vercel/next.js/discussions/66896) with Next.js in certain setups.

---

## What’s the Fix?

The solution: create a **custom client component** that checks if the schema tag is already present in the DOM before rendering it.

---

## The Solution: `useSkipHydration` Hook + `SchemaRenderer` Component

### `components/SchemaRenderer.tsx`

```typescript
"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`

```typescript
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} />
    </>
  );
}
```

> **Note**: You won’t see the script tag in the DOM using browser devtools in development (`npm run dev`). Instead, check the page source or build with `npm run build` and run with `npm run start` to verify the script is correctly rendered.

---

## Validating Your Structured Data

Use the following tools to ensure your schema is valid:

* [Google Structured Data Guide](https://developers.google.com/search/docs/appearance/structured-data)
    
* [Schema.org Validator](https://validator.schema.org/)
    
* [Google Rich Results Test](https://search.google.com/test/rich-results)
    
* [JSON-LD Playground](https://json-ld.org/playground/)
    

---

## How This Fix Actually Works – Under the Hood

### `useSkipHydration` Hook

```typescript
const useSkipHydration = (id: string) => {
  const [skipHydration, setSkipHydration] = useState(false);

  useEffect(() => {
    if (document?.getElementById(id)) {
      setSkipHydration(true);
    }
  }, [id]);

  return skipHydration;
};
```

This hook checks after mounting whether a script tag with the given `id` already exists in the DOM.

Why it’s important:

* The server already injected the `<script>` tag.
    
* During hydration, React tries to render it again.
    
* This hook detects the presence and prevents a second render.
    

### Conditional Rendering

```typescript
if (skipHydration) return null;
```

If the tag is already present, skip rendering it again. This avoids duplication of the script tags.

### Rendering the Schema

```typescript
<script
  id={id}
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
```

If the script tag isn’t present, this renders it.  
`dangerouslySetInnerHTML` is necessary to inject raw JSON into the script tag.

---

## Final Thoughts: Structured Data Done Right in Next.js

Implementing JSON-LD is one of the **simplest and most powerful** ways to improve your site’s SEO:

✅ Unlocks rich search results  
✅ Improves click-through rates  
✅ Helps search engines deeply understand your content

But with React Server Components and hydration quirks, doing it naively can cause duplication and broken validation.

This approach solves that cleanly:

* ✅ Works with SSR + hydration
    
* ✅ Prevents duplicate `<script>` tags
    
* ✅ Keeps your schema clean and Google-friendly
    

Whether it’s a blog, a product page, or a full-fledged app, this method keeps your structured data **robust, scalable, and SEO-optimized**.
