Next.js SEO: A Practical Guide for App Router and Pages Router

Next.js SEO: A Practical Guide for App Router and Pages Router

Stewart Moreland

Stewart Moreland

SEO in Next.js has a split personality right now. If you're on the App Router, you have a first-class metadata system, file-based conventions for sitemaps and robots files, and Server Components that hand crawlers fully rendered HTML without any JavaScript gymnastics [1]. If you're still on the Pages Router — or maintaining a codebase that mixes both — the story is more manual, and the gaps are easy to miss.

This post walks through what I've found to be the most important SEO concerns when building with Next.js, with the emphasis on App Router patterns that are now the recommended path forward.

Why Server Components change the crawlability equation

The fundamental SEO advantage of the App Router isn't metadata — it's rendering. Server Components produce HTML on the server, which means Googlebot and other crawlers receive fully formed content in the initial response [1]. There's no hydration gap, no useEffect data fetch that fires after the crawler has already moved on.

This matters most for content-heavy pages: product listings, blog posts, documentation. If your data was previously fetched client-side and rendered into the DOM after load, there's a real chance crawlers were indexing a skeleton. Server Components close that gap by default.

The practical implication: move your data fetching to the server. If you're reaching for useEffect to populate page content, that's a signal to reconsider the component boundary.

Mastering the Metadata API

The App Router's metadata export and generateMetadata function replace the scattered <Head> management that defined Pages Router SEO work. The key advantage is that Next.js handles deduplication and merging automatically — you define metadata at the layout level and override it at the page level without worrying about duplicate <title> tags or conflicting og:image entries [2].

For static pages, you export a metadata object directly. For dynamic routes — product pages, blog posts, user profiles — you use generateMetadata, which lets you fetch data and return metadata in the same async pattern you'd use for the page itself:

typescript
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const id = params.id
const product = await fetch(`https://.../${id}`).then((res) => res.json())
return {
title: product.title,
openGraph: {
images: ['/some-specific-page-image.jpg', ...parent.openGraph?.images ?? []],
},
}
}

A few things worth noting in this pattern: the parent parameter gives you access to resolved metadata from parent layouts, which is how the spread of previous images works above — you're extending rather than replacing the parent's Open Graph images. Next.js deduplicates the final output, so you don't end up with stacked <meta> tags.

Set metadataBase or your social previews will break

One detail that trips people up: relative URLs in openGraph.images don't resolve correctly without a metadataBase set ‡Next.js Docs — metadataBase. If you're generating absolute URLs for Open Graph or Twitter cards, define this in your root layout:

typescript
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
}

Without it, Next.js will warn in development and your social share images may render as broken links in production.

Technical SEO: file conventions that do the heavy lifting

One of the cleaner App Router additions is the set of file conventions for technical SEO assets. Instead of configuring a third-party plugin or hand-rolling a /api/sitemap route, you drop a file in the app/ directory and Next.js handles the rest.

Dynamic sitemaps with sitemap.ts

For sites with dynamic content, a static sitemap.xml file goes stale fast. The sitemap.ts convention lets you generate entries programmatically at request time (or at build time if the route is statically generated):

typescript
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetch('https://.../posts').then((res) => res.json())
const postEntries = posts.map((post) => ({
url: `https://acme.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
}))
return [
{
url: 'https://acme.com',
lastModified: new Date(),
},
...postEntries,
]
}

‡Next.js Docs — sitemap.ts

This file lives at app/sitemap.ts and is served at /sitemap.xml automatically. The TypeScript types from MetadataRoute.Sitemap keep you honest about the shape — url, lastModified, changeFrequency, and priority are all supported.

Crawl control with robots.ts

The same pattern applies to robots.txt. A robots.ts file at app/robots.ts returns a MetadataRoute.Robots object:

typescript
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/private/',
},
sitemap: 'https://acme.com/sitemap.xml',
}
}

‡Next.js Docs — robots.ts

For most sites this is straightforward, but it's worth being deliberate about what you're disallowing. Staging environments, admin routes, and paginated query strings are common candidates.

Canonical URLs

Next.js doesn't automatically inject canonical tags. You need to set them explicitly through the metadata export using the alternates.canonical field:

typescript
export const metadata: Metadata = {
alternates: {
canonical: 'https://acme.com/products/widget',
},
}

[3]

For dynamic routes, this belongs in generateMetadata alongside your other dynamic fields. Skipping canonicals is one of the most common technical SEO oversights on Next.js sites — especially on sites that support both trailing and non-trailing slash URLs.

Structured data and Open Graph images

JSON-LD in Server Components

Structured data (Schema.org JSON-LD) is one of the areas where Server Components simplify things. Because the component renders on the server, you can inject the <script type="application/ld+json"> tag directly in the component tree without worrying about hydration order or client-side timing [4].

typescript
export default async function Page({ params }) {
const product = await getProduct(params.id)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.image,
description: product.description,
}
return (
<section>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
{/* ... */}
</section>
)
}

Note the small HTML-entity escape to avoid breaking the closing </script> tag if user-generated content contains a < character.

Dynamic Open Graph images

The opengraph-image.tsx file convention lets you generate Open Graph images at the route level using ImageResponse from next/og [5]. This is useful for blog posts and product pages where you want the preview image to reflect the actual page content rather than a generic fallback. The file sits alongside your page.tsx, and Next.js handles the routing and caching automatically.

Pages Router: what's different

If you're on the Pages Router, the metadata and file-convention APIs described above aren't available. The standard approach is next-seo, which provides a <NextSeo> component and a DefaultSeo wrapper for global defaults. It handles <title>, Open Graph, Twitter cards, and canonical tags through a <Head> injection pattern.

The more significant limitation is rendering. Pages Router supports getServerSideProps and getStaticProps for server-side data fetching [6], which keeps content crawler-accessible. But any data fetching that happens in a client component — via useEffect, SWR, or React Query without SSR — produces content that may not be indexed reliably. This is the same problem Server Components solve structurally in the App Router.

Common pitfalls worth checking

Missing or inconsistent canonicals. Easy to skip, meaningful for sites with URL parameter variations (?ref=, ?page=). Set them explicitly in generateMetadata.

Metadata in Client Components. The metadata export and generateMetadata only work in Server Components. If you add 'use client' to a file that exports metadata, Next.js will silently ignore it.

Unescaped JSON-LD. As shown above, JSON.stringify alone isn't sufficient if your data comes from user input or external APIs.

metadataBase not set. Relative image URLs in Open Graph metadata will produce broken preview images on social platforms.

Sitemap not submitted. Generating a /sitemap.xml is only half the job. Submit it in Google Search Console and reference it in your robots.ts output.

No lastModified on sitemap entries. Omitting lastModified means crawlers have less signal about which pages to recrawl. Pull this from your CMS or database update timestamps.


The App Router has made the mechanical parts of Next.js SEO — metadata management, sitemaps, structured data — significantly more straightforward than they used to be. The bigger gains, though, come from the rendering model: content that's in the HTML from the first byte is content that gets indexed. That's the foundation everything else sits on.