Table of contents
- Quick summary
- Three mechanisms, four signals
- What 120 Next.js sites actually get wrong
- Add the JSON-LD block Next.js will not add for you
- Set title and description where the framework can see them
- The streaming trap that hides your title from AI crawlers
- Write the h1 yourself, once per page
- Scan it, then read the four booleans
- Where AIScan fits, and where it doesn't
- Ship the JSON-LD before your next deploy
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:
// 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.
// 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:
// 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:
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 instead if you would rather not install anything.
To check by hand, read the initial HTML the way a non-rendering crawler does:
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 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 explains what each field means, and the Next.js platform page 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 covers it. For the same four signals on a CMS, see the WordPress version of this guide, and the full setup walkthrough wires up robots.txt, sitemap and feed alongside it. Everything else lives in the guides index.
Frequently asked questions
My scan says title: false, but I set metadata in page.tsx. What went wrong?
The file is almost certainly a Client Component. Next.js only supports the metadata object and generateMetadata exports in Server Components, and it fails silently: no build warning, no runtime error, no tags in the head. Check the top of the file for 'use client'. If it is there, keep page.tsx as a Server Component, export the metadata from it, and move the interactive parts into a separate client file that page.tsx imports.
C3 reports JSON-LD: false, but I can see the ld+json script in browser devtools. Why?
Devtools shows you the DOM after JavaScript has run. The scan reads the initial HTML response. If the script tag is emitted by a Client Component, or by anything that only runs after hydration, it is not in that response and a non-rendering crawler never sees it. Run curl on the URL and grep for application/ld+json. If the count is zero there but non-zero in devtools, move the script into a Server Component.
The scan says h1: 0 but my page clearly has a big heading at the top.
A large heading is not automatically an h1. Two things cause this. Either the visible title is styled text inside a div or a p, which is common when a design system exposes a Heading component with a size prop but no semantic level, or the heading renders only on the client. Inspect the element and confirm the tag is literally h1, then confirm it survives a plain curl of the page.
Does the Next.js Metadata API have a JSON-LD field?
No. There is no jsonLd key on the metadata object and no file convention for structured data, the way there is for robots.ts, sitemap.ts and opengraph-image. The framework's own JSON-LD guide tells you to render a script tag of type application/ld+json inside your layout.js or page.js component. That is the supported route, and it is why structured data is the signal most often missing on Next.js sites.
Should the JSON-LD script go in the layout or the page?
Both work, and the choice follows the data. Site-wide entities that never change per route, such as Organization or WebSite, belong in the root layout so every page inherits them. Anything describing the specific content, such as Article, Product, Recipe or FAQPage, belongs in the page component where you already have the data. Two blocks on one page is valid; the check counts presence, not quantity.
My metadata is correct but only appears for some crawlers. Is streaming metadata the cause?
Very likely, if generateMetadata waits on runtime data. In that case Next.js streams the page and appends the resolved metadata tags after the body, which only helps clients that execute JavaScript. Next.js falls back to a blocking render for user agents on its HTML-limited bot list, and that list names Applebot, facebookexternalhit and several Google tokens but not GPTBot, ClaudeBot, PerplexityBot or CCBot. Make the metadata static, or widen the htmlLimitedBots pattern in next.config.
Do I need JSON-LD on every single page?
For the check, yes: it is evaluated per URL, so a scan of a page with no structured data reports JSON-LD: false even if your homepage has plenty. In practice the cheapest fix is one site-wide block in the root layout, giving every route a floor, plus a content-specific block on the templates that deserve one. Blog posts, product pages and documentation pages are worth the extra type; a contact page usually is not.
The scan reports h1: 2 and I only wrote one. Where is the second coming from?
From a layout above your page. Root layouts commonly render the site name or a logo wordmark as an h1, which then stacks with the page heading on every route. Search your app directory for h1 outside the page files. Demote the layout one to a p or a span with the same styling, and let title.template carry the site name into the title tag instead, where it belongs.
Related guides
How to ship one h1, title, meta description and JSON-LD on Webflow
Webflow's SEO panel now writes three of the four signals that AIScan's C3 check grades, and the fourth one is not in any panel. On a paid Site plan you can set a page's title tag, its meta…
Gate AI readiness in CI so a regression fails the build
A green pipeline is supposed to mean the site is fine. On an AI readiness check it often means something narrower: that the gate could not tell a file from a phantom. This step, which appears in a…
How to ship one h1, title, meta description and JSON-LD on WordPress
WordPress sites fail AIScan's C3 check more often than their owners expect, and almost never for the reason they expect. Across the 129 WordPress sites in our scan corpus, verified on 8 September…
The complete AI readiness setup for Wix in 2026
Every other platform in this series asks you to create something. Wix has already created it. Before you open a single panel, a Wix site is serving a robots.txt, a sitemap index, serverrendered HTML…
