---
title: "How to ship one h1, title, meta description and JSON-LD on Next.js"
slug: title-meta-schema-nextjs
published: 2026-09-09T13:20:38.801238+00:00
updated: 2026-09-09T13:20:38.801238+00:00
author: "Asif Rahman"
author_url: https://masifrahman.com
category: "AI Readiness"
tags: check:C3, platform:nextjs, Next.js, structured data, JSON-LD, App Router, technical SEO
description: "Next.js writes your title and meta description, but not your JSON-LD or your h1. Fix all four structured-HTML signals in the App Router, measured on 120 sites."
url: https://aiscan.site/blog/title-meta-schema-nextjs
---

Next.js hands you a Metadata API that writes your `<title>` and your meta description into the head automatically. It does not write your JSON-LD, and it does not write your `<h1>`. Those two are ordinary markup you render in a component, and on real sites that split is exactly where check C3 breaks. Across 120 Next.js sites in our own scan history, 34 of the 39 that miss C3 miss it on JSON-LD alone. This guide fixes all four signals in the App Router, in the order they actually fail.

## Quick summary

| C3 signal | Who writes it in Next.js | Where it lives | Time |
|---|---|---|---|
| `<title>` | Metadata API | `metadata` or `generateMetadata` in `layout.tsx` / `page.tsx` | 5 min |
| Meta description | Metadata API | the `description` field on the same export | 2 min |
| JSON-LD | You | a `<script type="application/ld+json">` in the component tree | 10 min |
| Exactly one `<h1>` | You | your page markup | 5 min |

Verified against the Next.js documentation at version 16.3.4, fetched from the framework's own docs site on 9 September 2026.

## Three mechanisms, four signals

C3 wants four things on the page and accepts only one arrangement of them: a heading count of exactly one, a `<title>` with text in it, a meta description, and structured data in at least one JSON-LD block. Next.js covers two of the four from a single export and leaves the rest to you.

The Metadata API is the covered half. According to the `generateMetadata` reference, metadata objects from every segment in a route are merged shallowly from the root layout down to the page, and duplicate keys are replaced by the last segment that defines them. So a `title.template` in `app/layout.tsx` decorates every child title, and a `description` set in the root layout survives on any page that never sets its own.

JSON-LD is the uncovered half, and the docs are direct about it. The Next.js JSON-LD guide says, verbatim: "Our current recommendation for JSON-LD is to render structured data as a `<script>` tag in your `layout.js` or `page.js` components." There is no `jsonLd` metadata field. The framework's own site follows that advice: a plain fetch of the Next.js homepage on 9 September 2026 returned two `application/ld+json` blocks and both sit in the body, not the head.

The `<h1>` has no framework mechanism at all. Nothing in the Metadata API touches body markup, so a single visible heading is yours to get right.

## What 120 Next.js sites actually get wrong

We grouped the C3 evidence strings across our own corpus on 9 September 2026, deduped to one latest scan per host on rubric `2026.08.2`. C3 passes on 248 of 501 sites. Of the 120 detected as Next.js, 81 pass and 39 do not.

| Failing pattern on Next.js | Sites |
|---|---|
| Everything correct except JSON-LD | **23** |
| No JSON-LD, plus a heading-count problem | 9 |
| Heading count wrong, JSON-LD present | 4 |
| Missing title or meta description | 3 |

Read the first row twice. Twenty-three of the 39 failures have one `<h1>`, a title, a description and no structured data: the framework doing its job, and nobody reaching the one step it left out. Our WordPress guide found the mirror image, where 50 of 57 failures were the heading count.

## Add the JSON-LD block Next.js will not add for you

Render it inside the page component, next to your content. According to the same guide, `JSON.stringify` does not sanitise strings used in injection attacks, so replace `<` with its unicode escape:

```tsx
// app/blog/[slug]/page.tsx
export default async function Page({ params }) {
  const { slug } = await params
  const post = await getPost(slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    datePublished: post.date,
  }

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
      <h1>{post.title}</h1>
      {/* ... */}
    </article>
  )
}
```

Use a native `<script>` rather than `next/script`. In its own words, the guide notes that `next/script` is built for loading executable JavaScript, and structured data is not code.

## Set title and description where the framework can see them

Both come from one export, and both have a rule that fails quietly.

```tsx
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: { template: '%s | Acme', default: 'Acme' },
  description: 'One sentence describing the site, 150 to 160 characters.',
}
```

The quiet rule, according to the reference: the `metadata` object and `generateMetadata` exports are only supported in Server Components. Put `export const metadata` in a file carrying `'use client'` and nothing is emitted, no warning appears, and C3 reports `title: false`. The reason given is that metadata has to resolve on the server before the page renders so it can go into the initial HTML response. Keep `page.tsx` a Server Component and move interactive parts into their own client file.

## The streaming trap that hides your title from AI crawlers

When `generateMetadata` waits on runtime data, Next.js streams the page first. The reference says the resolved tags are then "appended to the `<body>` tag", and adds that this was verified with bots that execute JavaScript and inspect the full DOM. Crawlers that cannot run JavaScript get a blocking render instead, and Next.js decides which is which from the user agent.

That list is a single regular expression, `HTML_LIMITED_BOT_UA_RE`, in the framework's source. We fetched it on 9 September 2026 and matched it against the 33 crawler tokens in our own AI crawler inventory.

| Matched by the blocking list | Not matched |
|---|---|
| Applebot, Applebot-Extended | GPTBot, ChatGPT-User, OAI-SearchBot |
| `facebookexternalhit` | ClaudeBot, Claude-User, Claude-SearchBot |
| Five Google tokens, including Google-Extended | PerplexityBot, Perplexity-User, CCBot |
| **8 of 33** | Amazonbot, `meta-externalagent`, and 16 more: **25 of 33** |

None of the eleven crawlers last measured as non-rendering appears on it. Googlebot is absent too, and correctly so, because it renders JavaScript. The others do not have that excuse. If your metadata defers to request time, add the crawlers you care about:

```ts
// next.config.ts
const config = {
  htmlLimitedBots: /GPTBot|ClaudeBot|PerplexityBot|CCBot|facebookexternalhit/i,
}
export default config
```

Or make the metadata static, which is faster and needs no list. The docs note that overriding this option can lengthen response times.

## Write the h1 yourself, once per page

Thirteen of the 39 failing Next.js sites report a heading count other than one, ten of them zero. Both causes are layout-shaped: a root layout rendering a site name as `<h1>` while the page renders another, or a visible title that is styled text in a `<div>`. Put exactly one `<h1>` in the page component, keep the site name in the layout as a `<p>` or a `<span>`, and let the title tag carry the branding through `title.template`.

## Scan it, then read the four booleans

Run the scan first. It reads all four signals in one pass and prints the evidence string C3 decided on:

```bash
npx aiscan-cli yoursite.com
```

Look at the **C3** row. It returns `h1: N, title: bool, meta description: bool, JSON-LD: bool`, and only `h1: 1` with all three true is a pass. You can paste the URL at [AIScan](https://aiscan.site/) instead if you would rather not install anything.

To check by hand, read the initial HTML the way a non-rendering crawler does:

```bash
URL=https://yoursite.com
curl -sL --compressed "$URL" -o /tmp/p.html
grep -c '<h1' /tmp/p.html
grep -o '<title>[^<]*</title>' /tmp/p.html
grep -c 'name="description"' /tmp/p.html
grep -c 'application/ld+json' /tmp/p.html
```

You want 1, one line of real text, 1, and at least 1. Validate the block itself with Google's Rich Results Test or the Schema Markup Validator, both named in the Next.js guide.

## Where AIScan fits, and where it doesn't

C3 records whether a JSON-LD block is present. It does not report which `@type` you declared, whether the graph parses, or whether the description is unique across your pages. A page can pass C3 with an empty `Organization` stub. Treat the row as a floor, not a grade, and use a schema validator for the contents.

If this Next.js app is the front end for a headless WordPress install, the structured data, robots rules, sitemaps and llms.txt still come from the WordPress origin. [ThinkRank](https://thinkrank.ai) keeps the schema graph, the robots rules, the sitemaps and llms.txt under one plugin, which spares you the usual argument between three of them over the same file, and it reads your current configuration straight out of Rank Math, Yoast, All in One SEO or SEOPress rather than making you type it again. Any of those four will write a decent JSON-LD graph if one is already installed. None of them can reach into your React tree.

## Ship the JSON-LD before your next deploy

Add the script tag to your page component, confirm the metadata export is not in a client file, and scan again. The **C3** row on [the content checks page](https://aiscan.site/docs/checks/content) explains what each field means, and [the Next.js platform page](https://aiscan.site/docs/platforms/nextjs) collects the rest of the stack. If your scan also reported low word counts or an empty body, the render is the deeper problem and [our guide to React sites that go blank for AI crawlers](https://aiscan.site/blog/nextjs-react-invisible-to-ai-crawlers) covers it. For the same four signals on a CMS, see [the WordPress version of this guide](https://aiscan.site/blog/title-meta-schema-wordpress), and [the full setup walkthrough](https://aiscan.site/blog/ai-readiness-setup-nextjs) wires up robots.txt, sitemap and feed alongside it. Everything else lives in [the guides index](https://aiscan.site/guides).

