DEV TOOLBOX · SCHEMA MARKUP · NO SIGNUP
> schemapreview

How to add JSON-LD to Next.js (App Router)

Last verified: 2026-05-15

How to add JSON-LD to Next.js (App Router)

The official Next.js docs show three different ways to add JSON-LD, and the one most blog posts still recommend (<Head> from next/head) doesn't work in App Router. Here is what actually works in Next.js 15.

Why next/head is wrong for App Router

next/head is a Pages Router primitive. In App Router (the default since Next 13.4), it's a no-op — the head is managed by the metadata export and route-level conventions, not by component imports.

If you have a blog post from 2022 that tells you to wrap your JSON-LD in <Head>, the resulting markup will silently not appear in the rendered HTML. View Source on your page to confirm.

Why generateMetadata won't help

generateMetadata is the App Router's replacement for next/head, but it's narrowly scoped to the standard metadata shape: title, description, OpenGraph, Twitter card, robots. It does not accept arbitrary <script> tags. There is no metadata.scripts field.

What actually works

Put the <script type="application/ld+json"> directly in your server component, with the JSON serialised via dangerouslySetInnerHTML. Server components run at build time (for static export) or per request (for server rendering), so the script ends up in the HTML the crawler sees.

Example: Recipe schema on a recipe page

// app/recipes/[slug]/page.tsx
import { getRecipe } from '@/lib/recipes';

export default async function RecipePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const recipe = await getRecipe(slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Recipe',
    name: recipe.name,
    image: recipe.image,
    author: { '@type': 'Person', name: recipe.author },
    recipeIngredient: recipe.ingredients,
    recipeInstructions: recipe.steps.map((s) => ({
      '@type': 'HowToStep',
      text: s,
    })),
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{/* page content */}</article>
    </>
  );
}

The script lives inside the JSX of a server component. Next renders it as part of the static HTML. View Source confirms it.

Example: Organization schema in layout.tsx

For site-wide schema like Organization, put it in app/layout.tsx. It will render on every page:

// app/layout.tsx
const orgLd = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'DevToolbox',
  url: 'https://devtoolbox.example.com',
  logo: 'https://devtoolbox.example.com/logo.png',
  sameAs: ['https://twitter.com/devtoolbox', 'https://github.com/devtoolbox'],
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(orgLd) }}
        />
        {children}
      </body>
    </html>
  );
}

Avoid double-escaping

The single most common mistake: writing the JSON inline as a string with manual escaping. Like:

// don't do this
dangerouslySetInnerHTML={{
  __html: `{ "name": "${recipe.name}" }`,
}}

If recipe.name contains a quote, an apostrophe, or a <, the JSON breaks. Use JSON.stringify and let it handle escaping. It produces valid JSON for any input.

Testing

After deploying, do not trust Inspect Element in the browser — it shows the live DOM, which can include client-side mutations. Always check View Source (Ctrl+U on most browsers). The <script type="application/ld+json"> should be present there.

Then paste the rendered URL into the URL validator on the homepage or Google's Rich Results Test.

One caveat I haven't fully tested

For pages using 'use client' at the top, the same <script> pattern works but renders during hydration, not as part of the initial HTML. Google's crawler claims to execute JavaScript, but in practice client-rendered schema is unreliable. I have not measured the difference rigorously — if you have data, email me.

For everything else, the inline <script> in a server component is the path.

Generate your own schema with the builder on the homepage and paste the snippet straight into page.tsx.

Related entities
Related guides