Next.js SEO in the App Router: What Actually Changed
Search Google for nextjs seo and the first result is nextjs.org/learn/seo, Vercel's own course. Open the metadata chapter and the first code block is this:
import Head from 'next/head'
function IndexPage() {
return (
<div>
<Head>
<title>iPhone 12 XS Max For Sale in Colorado</title>
<meta name="description" content="..." key="desc" />
</Head>
<h1>iPhones for Sale</h1>
</div>
)
}
That is Pages Router. next/head does nothing in the App Router. If you paste that into app/page.tsx, you get no title, no description, and no error loud enough to notice. The single most authoritative page on this topic teaches an API that silently no-ops in the router every new Next.js project has used since 13.4.
This article is the App Router version, checked against the Next.js docs and Google's own documentation rather than against other blog posts. Every code sample is written for the App Router. Where a behavior depends on a Next.js version, the version is stated.
Why the ranking guides are behind
It helps to understand why this SERP looks the way it does, because it tells you what to trust.
Search result pages age at the speed of the thing they describe. nextjs.org/learn/seo was written when Pages Router was the only router. It ranks first because it sits on a domain with enormous authority and it has accumulated links for years, not because it was updated. Google has no signal that says "this code no longer runs". A page can be accurate, then become wrong, and keep its position for a long time afterward.
Below it, the SERP is Reddit threads (r/nextjs, "Next.js SEO Complete Checklist"), YouTube crash courses, a Strapi guide, and the npm page for next-seo. The next-seo package is worth a specific warning: it exists to manage <head> tags in the Pages Router. In the App Router the framework does that job natively, and reaching for the package usually means you have not found the built-in API yet.
The practical consequence: when you search for a Next.js SEO answer, check the router before you check the advice. If the snippet imports next/head, uses _document.js, or wraps things in getStaticProps, it is answering a different question than the one you have.
What Googlebot actually does with a Next.js page
Before any API, the mental model. Google documents processing JavaScript in three phases: crawling, rendering, indexing. From Google's JavaScript SEO documentation:
Googlebot queues pages for both crawling and rendering. It is not immediately obvious when a page is waiting for crawling and when it is waiting for rendering.
And on how long that wait can be:
The page may stay on this queue for a few seconds, but it can take longer than that.
This is the whole reason server rendering matters. Anything present in the HTML response is available at crawl time. Anything that only appears after JavaScript executes waits for the render queue, which Google explicitly declines to put a bound on.
Next.js gives you server rendering by default in the App Router, so most of this is handled. The failure mode is not "Next.js does not server render", it is "this particular component opted out and took the content with it". The check is one command:
curl -s https://yourdomain.com/blog/some-post | grep -c "<h1"
If that returns 0, your <h1> is not in the HTML. Do the same for your body copy. curl is a better test than the browser's View Source in one respect: it cannot accidentally run JavaScript, so what you see is exactly what a crawler receives before rendering.
This test matters more, not less, for AI assistant crawlers. Googlebot at least renders eventually. The retrieval bots behind assistants are generally fetch-and-parse, with no rendering step at all, which is covered in more depth in how to rank on ChatGPT. A page that needs JavaScript to show its content is invisible to them permanently rather than temporarily.
Two more things from the same Google document that bite single-page apps and are worth knowing even if you never hit them. On canonicals:
You can use JavaScript to set the canonical URL, but keep in mind that you shouldn't use JavaScript to change the canonical URL to something else than the URL you specified as the canonical URL in the original HTML.
And on routing, use the History API rather than fragments, because Googlebot needs real URLs to extract and queue. Next.js routing already does this, so you get it free unless you build your own client-side view switching on top.
Metadata in the App Router
Two APIs replace next/head, and both are exports rather than components.
Static metadata is an exported object from layout.tsx or page.tsx:
// app/pricing/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Pricing',
description: 'One plan, thirty articles a month.',
}
export default function Page() {
return <h1>Pricing</h1>
}
Dynamic metadata is a function. Note the shape of params, which is the detail most half-updated tutorials get wrong:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.description,
}
}
params is a Promise and must be awaited. Code written for Next.js 13 or 14 destructures it directly, and that version of the snippet is still all over the search results.
Three rules that cause real bugs:
Both exports are Server Component only. Put 'use client' at the top of a page and its metadata export stops working. The fix is to keep page.tsx as a server component and push the interactive part into a child:
// app/dashboard/page.tsx
import type { Metadata } from 'next'
import { InteractiveChart } from './interactive-chart'
export const metadata: Metadata = { title: 'Dashboard' }
export default function Page() {
return <InteractiveChart />
}
You cannot export both metadata and generateMetadata from the same route segment.
Merging is shallow. Metadata resolves from the root layout down, and duplicate keys are replaced, not deep merged. If your root layout sets openGraph.title and openGraph.description, and a page sets only openGraph.title, the page loses the inherited description entirely. The whole openGraph object is replaced. The fix is to pull shared fields into a variable and spread them:
// app/shared-metadata.ts
export const sharedOpenGraph = {
siteName: 'Acme',
images: ['/og.png'],
locale: 'en_US',
}
// app/blog/[slug]/page.tsx
import { sharedOpenGraph } from '@/app/shared-metadata'
export async function generateMetadata({ params }) {
const post = await getPost((await params).slug)
return {
openGraph: { ...sharedOpenGraph, title: post.title, type: 'article' },
}
}
Streaming metadata, and why your og:image may not be in the head
This one is genuinely new behavior and it is absent from every guide currently ranking for this keyword.
Since Next.js 15.2.0, generateMetadata streams. For a dynamically rendered page, Next.js sends the initial UI without waiting for generateMetadata to resolve, and when it does resolve, the metadata tags are appended to the <body> tag rather than sitting in <head>.
Next.js states it has verified this is interpreted correctly by bots that execute JavaScript and inspect the full DOM, naming Googlebot. For bots that cannot execute JavaScript, Next.js detects them by User Agent and falls back to blocking, so those crawlers get the tags in <head> as before. The default list of HTML-limited bots is maintained in the Next.js repository and includes agents like facebookexternalhit.
Where this surprises people: you curl a dynamic page, grep for og:image, find nothing in the head, and conclude your metadata is broken. It is not broken, it is streamed. But it does mean an unlisted scraper, a preview bot for some chat app, or an internal tool that reads only the <head> will miss your tags.
If you need the old blocking behavior, that is a config flag:
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
htmlLimitedBots: /.*/,
}
export default config
Next.js is direct about the trade-off: overriding htmlLimitedBots can lead to longer response times, and the default is sufficient for most cases. Streaming exists because it reduces TTFB and can lower LCP. Turn it off only if you have a specific crawler that is actually missing tags, not on principle.
Prerendered pages never stream metadata, because it is resolved at build time. If your pages are static, this section does not apply to you at all.
Avoiding the double fetch
generateMetadata and the page component usually need the same data. fetch requests are automatically memoized across generateMetadata, generateStaticParams, layouts, and pages. If you are hitting a database instead, fetch memoization does not apply and you need React's cache:
// app/lib/data.ts
import { cache } from 'react'
import { db } from '@/app/lib/db'
export const getPost = cache(async (slug: string) => {
return db.query.posts.findFirst({ where: eq(posts.slug, slug) })
})
Import that in both places and the query runs once per request. Skip it and every page render queries twice, which is invisible in development and obvious in your database metrics.
Canonicals and metadataBase
Set metadataBase once in the root layout, then use relative paths everywhere below it:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
title: {
template: '%s | Acme',
default: 'Acme',
},
}
Two things to know. Using a relative path in a URL-based metadata field without metadataBase configured is a build error, so you will find out immediately. And title.template applies to child segments only, never to the segment where it is declared, which is why title.default is required alongside it.
Canonicals go under alternates:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params
return {
alternates: { canonical: `/blog/${slug}` },
}
}
The URL composition rule here is unusual enough to trip people. Next.js treats an "absolute" path in a metadata field as relative to the end of metadataBase, favoring developer intent over standard directory traversal. Given metadataBase of https://acme.com, all of payments, /payments, ./payments and ../payments resolve to https://acme.com/payments. If you have a base path, for example https://acme.com/start/from/here, do not assume a leading slash escapes it. Print the rendered <link rel="canonical"> and read it.
Canonicals matter most where your app generates URL variants: query parameters from filters, tracking parameters, trailing-slash duplicates. Every one of those is a separate URL to a crawler, and picking which page keyword-by-keyword is one of the questions covered in SEO for SaaS.
sitemap.ts and robots.ts are code, not files
Both are file conventions that export a function. This is the part of Next.js SEO that is genuinely better than the alternative, because your sitemap cannot drift from your content.
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/blog'
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts()
const lastModified = new Date()
return [
{ url: 'https://acme.com', lastModified, changeFrequency: 'weekly', priority: 1 },
{ url: 'https://acme.com/docs', lastModified, changeFrequency: 'monthly', priority: 0.8 },
...posts.map((post) => ({
url: `https://acme.com/blog/${post.slug}`,
lastModified: new Date(post.publishedAt),
changeFrequency: 'monthly' as const,
priority: 0.7,
})),
]
}
One decision in there is worth stating out loud, because it is where most generated sitemaps go wrong. lastModified for the static pages is the build date, which is honest, since a static page can only change when you deploy. lastModified for an article is its actual publication date, not the build date. If you map every URL to new Date(), every article claims to have changed on every deploy, and the field stops carrying information.
robots.ts is the same idea:
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard', '/onboarding'],
},
],
sitemap: 'https://acme.com/sitemap.xml',
host: 'https://acme.com',
}
}
Disallow the authenticated surface. Those routes return redirects to a crawler and produce nothing but noise in your coverage reports.
For large sites, Google's limit is 50,000 URLs per sitemap and you split with generateSitemaps. There is a version-specific gotcha here: as of Next.js 16.0.0, the id argument is a Promise that resolves to a string. Code copied from a 15.x tutorial will hand you a Promise where you expected a number, and your arithmetic silently produces NaN:
// app/product/sitemap.ts
import type { MetadataRoute } from 'next'
export async function generateSitemaps() {
return [{ id: 0 }, { id: 1 }, { id: 2 }]
}
export default async function sitemap(props: {
id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id)
const start = id * 50000
const products = await getProducts(start, start + 50000)
return products.map((p) => ({ url: `https://acme.com/product/${p.id}` }))
}
Both files are Route Handlers cached by default unless they use a request-time API. Under the default setup that means your sitemap is generated at build time. If you publish content between deploys, through a CMS or an API, the sitemap on disk goes stale until the next build. Either trigger a rebuild on publish or make the route dynamic. This is exactly the case that catches teams who add a headless blog to a statically built marketing site.
JSON-LD without opening an XSS hole
Next.js recommends rendering structured data as a plain <script> tag inside layout.js or page.js. Not next/script, which is built for loading executable JavaScript. JSON-LD is data.
The security detail matters and is skipped by most guides that show this snippet:
export default async function Page({ params }) {
const { slug } = await params
const post = await getPost(slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedAt,
author: { '@type': 'Organization', name: 'Acme' },
}
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
<h1>{post.title}</h1>
</article>
)
}
JSON.stringify does not sanitize strings used in XSS injection. The .replace(/</g, '\\u003c') is what closes it: any < in your data, including a </script> that a user put in a post title, becomes a unicode escape that JSON parsers still read correctly but the HTML parser cannot act on. Next.js suggests scrubbing HTML tags from the payload, and points at serialize-javascript if you want a maintained alternative to hand-rolling it.
If you want types on the object, schema-dts gives you WithContext<BlogPosting> and will tell you at compile time when you invent a property that does not exist.
Validate the output with Google's Rich Results Test or the Schema Markup Validator. Do not assume valid JSON means valid schema. They are different checks and the second one fails far more often.
Status codes are where Next.js will quietly hurt you
Google's JavaScript documentation lists soft 404s as a specific single-page-app failure, and recommends either a JavaScript redirect to a URL that returns a real 404, or adding <meta name="robots" content="noindex"> to error pages. In the App Router the equivalent is notFound(), and it has a behavior that catches people:
Next.js will return a
200HTTP status code for streamed responses, and404for non-streamed responses.
Read that twice. Calling notFound() does not guarantee a 404 status. If the response is streaming, which is the normal case for a dynamically rendered page, the status line has already been sent by the time your code decides the post does not exist. HTTP does not let you take it back. The user sees your not-found UI, and the crawler sees 200 OK on a page whose content says the thing is missing. That is the textbook definition of a soft 404.
The mitigation Next.js applies is automatic: it injects <meta name="robots" content="noindex" /> for pages that return a 404 status code. But that helps the non-streamed case, which was already fine.
The practical approach is to not rely on notFound() for anything a crawler will discover at scale. Check what your app actually returns:
curl -s -o /dev/null -w "%{http_code}\n" https://acme.com/blog/does-not-exist
If that prints 200, you have a soft 404. Fix it upstream instead of downstream: resolve whether the resource exists before you start streaming. In practice that means either prerendering the valid paths so unknown ones never reach your page component, or doing the existence check in middleware or a proxy where you still control the status line.
The reason this matters more than it sounds: soft 404s consume crawl budget on URLs that will never rank, and Google's coverage reports will fill with pages it declines to index for reasons you have to go digging to understand.
generateStaticParams decides which pages exist
generateStaticParams is the App Router replacement for getStaticPaths, and it is the lever that controls both build output and what 404s.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
Return the full list and every post is prerendered at build time, which is the ideal case for a blog: fully static HTML, no render queue, no cold start, correct status codes.
Three details worth knowing before you tune it:
Returning a subset is a deliberate trade. posts.slice(0, 10) prerenders your top ten and leaves the rest to be generated on first visit. That shortens builds on large sites at the cost of the first visitor to each remaining page, and that first visitor is sometimes Googlebot.
dynamicParams = false turns everything else into a 404. Add export const dynamicParams = false and only paths from generateStaticParams are served. This is a useful guard against an infinite URL space, for example a [category] segment where anything typed into the URL currently renders an empty page. It is also an excellent way to accidentally 404 your entire archive if your data source hiccups during a build.
You must always return an array, even an empty one. Otherwise the route is dynamically rendered. Returning [] means all paths render statically on first visit rather than at build time.
One build-time behavior is easy to miss: during revalidation, generateStaticParams is not called again. New slugs that appear after the build do not get picked up by ISR alone. This is the same class of problem as the stale sitemap above, and it usually has the same root cause, which is a static build treating a growing content set as if it were fixed.
Verify it instead of trusting it
A build that compiles proves nothing about what a crawler sees. Four checks, all runnable, that catch most of what goes wrong:
Is the content in the HTML?
curl -s https://acme.com/blog/some-post | grep -o "<title>[^<]*</title>"
curl -s https://acme.com/blog/some-post | grep -c "<h1"
Does the sitemap contain what you think?
curl -s https://acme.com/sitemap.xml | grep -c "<loc>"
Compare that number against your actual post count. A mismatch means either a stale build or a filter you forgot about.
Is the canonical self-referencing?
curl -s https://acme.com/blog/some-post | grep -o '<link rel="canonical"[^>]*>'
A canonical pointing at your homepage, or at a staging domain, is a common copy-paste result and it tells Google not to index the page.
Is metadata streamed or blocking? Compare the head against the full document:
curl -s https://acme.com/some-page | sed -n '/<head>/,/<\/head>/p' | grep -c "og:"
curl -s https://acme.com/some-page | grep -c "og:"
If the second number is higher than the first, your metadata is streaming into the body. That is expected behavior on a dynamic page and fine for Googlebot, per the Next.js documentation above.
Past that, Search Console's URL Inspection tool shows you the rendered HTML Google actually holds, which is the only source of truth for what got indexed. It has a quota of 2,000 queries per day per site, so it works fine as a spot check and not as a monitoring loop.
What the framework does not do for you
Everything above is table stakes. It gets your pages crawlable, correctly labeled, and honestly described. None of it decides whether you rank.
The uncomfortable part is that these are the easy problems. They have documented answers, they are verifiable with curl, and once they are right they stay right. The hard problem is that a technically flawless page about a subject nobody searches for, or one aimed at the wrong reader, earns nothing. We have watched keywords with excellent volume and low difficulty turn out to be searched entirely by people looking to hire an agency, which is worth zero to a self-serve product no matter how clean the markup is.
So the honest sequence is: get the technical layer right once, because it is cheap and it compounds, then spend your actual time on which pages to write and for whom. Nobody can guarantee a ranking or a timeline from either half, and any tool or agency that offers you one is selling something. What you can control is that the pages you do publish are reachable, correctly described, and aimed at the person who would actually buy.
Two adjacent pieces if you are working through this: SEO for SaaS covers picking keywords by who ranks rather than by volume, and how to rank on ChatGPT covers the crawler and rendering rules for AI assistants, which overlap with this article but are not identical. Server rendering helps you in both, for related reasons.
FAQ
Is Next.js good for SEO?
Yes, with a caveat that has nothing to do with the framework. Next.js server renders by default in the App Router, which puts your content in the HTML response instead of behind the render queue Google describes. The metadata, sitemap, and robots APIs are built in. What Next.js cannot do is make a page worth ranking. Treat the framework as removing technical obstacles rather than as an advantage over competitors who are also on it.
Does next/head work in the App Router?
No. next/head is a Pages Router API. In the App Router it does not produce tags, and it fails quietly rather than throwing, which is why the mistake survives so long. Use the metadata export for static values and generateMetadata for dynamic ones. Both are Server Component only. This is worth checking first when a page shows the wrong title in search results.
Do I need the next-seo package?
Almost certainly not, if you are on the App Router. next-seo exists to manage <head> tags in the Pages Router, a job the framework now does natively with better type safety and file-based conventions for OG images, sitemaps, and robots. It still ranks in the top 10 for this keyword because the package is genuinely popular and old, not because it is the current answer.
Why is my metadata missing from the head when I curl the page?
Most likely it is streaming, not missing. Since Next.js 15.2.0, generateMetadata on a dynamically rendered page resolves after the initial UI is sent, and the tags are appended to the <body>. Next.js states this is read correctly by bots that execute JavaScript, including Googlebot, and it falls back to blocking for HTML-limited bots detected by User Agent. Prerendered pages are unaffected. If a specific crawler is genuinely missing your tags, htmlLimitedBots in next.config.ts controls the behavior, at some cost in response time.
How do I add SEO metadata to a client component page?
You move the metadata out of the client boundary rather than trying to get it in. Keep page.tsx as a Server Component with its metadata or generateMetadata export, and move the 'use client' code into a child component that the page renders. The page stays server rendered, the metadata resolves on the server, and the interactive part hydrates as normal.
Does a Next.js sitemap update automatically when I publish?
Only if the route is regenerated. sitemap.ts is a Route Handler cached by default, so with a standard static build the sitemap is produced at build time. Content published afterward through a CMS or API will not appear until the next build. Either trigger a rebuild on publish or make the route resolve at request time. Checking curl -s https://yoursite.com/sitemap.xml | grep -c "<loc>" against your real post count catches this in one command.
Get cited by ChatGPT. Rank on Google.
You found this article through search. That is the whole product.
- One researched article a day
- Published on your own domain
- Keywords checked against live results
Get cited by ChatGPT. Rank on Google.
You found this article through search. That is the whole product.
- One researched article a day
- Published on your own domain
- Keywords checked against live results
