1. Home
  2. Insights
  3. Web Development

Next.js SEO Checklist for the App Router: 12 Checks With Code

A 12-point Next.js SEO checklist for the App Router, with code that follows the Next.js 16.3 docs: metadata, canonicals, sitemaps, rendering, JSON-LD, hreflang, redirects and testing.

Abstract cover illustration for a Next.js SEO checklist, showing the article title with short notes on metadata, sitemaps and rendering in the App Router.
Illustration: Apex Logic
On this page
  1. Next.js SEO checklist at a glance
  2. How to set metadata in the Next.js App Router
  3. Canonical URLs in Next.js
  4. sitemap.ts and robots.ts
  5. Static, dynamic or streaming: what crawlers see
  6. How to add JSON-LD structured data in Next.js
  7. Images, fonts and Core Web Vitals
  8. hreflang and internationalization in Next.js
  9. Redirects and 404 pages in Next.js
  10. How to test Next.js SEO before and after launch
  11. Next.js SEO FAQ

Quick answer

To make a Next.js App Router site ready for search, set metadataBase and page metadata with the Metadata API, give every indexable page a canonical URL, and generate sitemap.xml and robots.txt with sitemap.ts and robots.ts. Keep indexable content in the server-rendered HTML, add JSON-LD, use next/image and next/font for Core Web Vitals, declare hreflang for each language, and return real 308 redirects and 404s.

  • Set metadataBase once, then title, description and canonical per page
  • sitemap.ts and robots.ts generate both files from code
  • Streaming is server-rendered, but the status code is fixed once it starts
  • Test the HTML crawlers receive, not just what the browser shows

A Next.js SEO checklist for the App Router comes down to five jobs: complete metadata from the Metadata API, one canonical URL per page, a sitemap.xml and robots.txt generated from code, indexable content in the server-rendered HTML, and correct status codes for redirects and missing pages. Add JSON-LD, fast images and fonts, and hreflang if you publish in more than one language, then test what crawlers actually receive.

The code below follows the documentation for Next.js 16.3, the current release as of September 2026, where route params is a Promise you await and middleware.ts is now proxy.ts. Still choosing a framework? Start with our React vs Next.js comparison.

Next.js SEO checklist at a glance

#CheckNext.js toolDone when
1Site-wide metadatametadataBase, title templateUnique title and description per page
2Metadata from datagenerateMetadataDynamic pages describe themselves
3Canonical URLsalternates.canonicalEach page points to its clean URL
4Social previewsopenGraph, opengraph-imageShared links show the right card
5Sitemapapp/sitemap.tsAll indexable URLs, real dates
6Robotsapp/robots.ts, robots metadataPrivate areas blocked, staging noindex
7RenderingStatic routes, generateStaticParams, SuspenseMain content in the initial HTML
8Structured dataJSON-LD script tagPasses the Rich Results Test
9Images and fontsnext/image, next/fontLCP image preloaded, no layout shift
10Core Web VitalsuseReportWebVitalsLCP, INP and CLS pass
11Languagesalternates.languages, app/[lang]All versions linked, plus x-default
12Redirects and 404sredirects, permanentRedirect, notFound308 for moves, 404 for missing pages

How to set metadata in the Next.js App Router

Set the defaults once in the root layout. metadataBase lets every other URL field use a relative path, and a title template keeps titles consistent:

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

export const metadata: Metadata = {
  metadataBase: new URL('https://www.example.com'),
  title: {
    template: '%s | Example Co',
    default: 'Example Co',
  },
  description: 'What the site offers, in one plain sentence.',
  openGraph: { siteName: 'Example Co', type: 'website' },
}

The metadata object and generateMetadata work only in Server Components, and a route segment can export one, not both. By default Next.js adds just the charset and viewport tags.

generateMetadata for dynamic pages

For posts, products and other pages built from data, read the route params and return metadata from the same data the page uses. Wrap the data function in React's cache so the page and its metadata share one request:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/app/lib/data' // your data function, wrapped in cache()

type Props = { params: Promise<{ slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: '/blog/' + slug },
    openGraph: { siteName: 'Example Co', type: 'article', publishedTime: post.publishedAt },
  }
}

Here getPost, getAllPosts and the post fields stand in for your own data layer. Metadata merges shallowly: a page's openGraph object replaces the layout's, so siteName is repeated. For per-page share images, add an opengraph-image.tsx file; file-based metadata overrides the metadata object.

Since Next.js 15.2, metadata on dynamically rendered pages can stream in after the first HTML. The docs say Googlebot reads it correctly, while HTML-limited bots such as Twitterbot, Slackbot and Bingbot are detected by user agent and get the tags in the <head> first. Prerendered pages resolve metadata at build time.

Canonical URLs in Next.js

Give every indexable page a self-referencing canonical with alternates.canonical, as above. With metadataBase set, the relative path becomes an absolute URL, which is what Google recommends. Then remove the duplicates a canonical has to fight:

  • Pick one host (www or not) and one protocol, and redirect the others.
  • Keep trailing slashes consistent. By default Next.js redirects /about/ to /about; trailingSlash: true reverses that.
  • Leave tracking parameters out of canonical URLs.

Google treats a canonical as a strong signal, not a command, and advises against using robots.txt or noindex to choose one.

sitemap.ts and robots.ts

Generate both files from code. app/sitemap.ts returns an array of URLs served at /sitemap.xml:

// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/app/lib/data'

const BASE_URL = 'https://www.example.com'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()

  return [
    { url: BASE_URL },
    ...posts.map((post) => ({
      url: BASE_URL + '/blog/' + post.slug,
      lastModified: post.updatedAt,
    })),
  ]
}

Use real change dates for lastModified: Google ignores priority and changefreq and uses lastmod only when it is consistently accurate. One sitemap holds at most 50,000 URLs or 50 MB uncompressed; split larger ones with generateSitemaps, whose id argument is a Promise since Next.js 16.

app/robots.ts builds robots.txt the same way:

// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/', disallow: '/admin/' },
    sitemap: 'https://www.example.com/sitemap.xml',
  }
}

Robots.txt controls crawling, not indexing: Google says it will not keep a page out of search, so use noindex or a password. On staging, set robots: { index: false } in the root layout metadata from an environment variable.

Static, dynamic or streaming: what crawlers see

Every App Router page is rendered to HTML on the server. What changes is when:

  • Static routes are prerendered at build time. For dynamic segments, generateStaticParams lists the paths to prerender.
  • Dynamic routes render per request, for example when they read cookies, headers or search params.
  • Streaming, through loading.js or Suspense, sends HTML in chunks within one response; the docs say it is server-rendered and does not affect SEO.

The catch is status codes. Once streaming starts, the server has already sent 200 OK. A notFound() that fires mid-stream cannot become a 404, so Next.js adds a noindex robots tag instead, and a redirect() mid-stream becomes a client-side redirect. Check that the page exists before any Suspense boundary:

// app/blog/[slug]/page.tsx (same file as generateMetadata)
import { Suspense } from 'react'
import { Comments } from './comments'

export default async function Page({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound() // real 404, before any Suspense boundary

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
      <Suspense fallback={<p>Loading comments...</p>}>
        <Comments postId={post.id} />
      </Suspense>
    </article>
  )
}

With Cache Components on (opt-in in Next.js 16), HTML-limited bots get the page rendered on request rather than the prerendered shell, so shell data must also work at request time. Content fetched in the browser after load is not in the initial HTML, and not every bot runs JavaScript, so Google still recommends server-side rendering or prerendering; see our JavaScript SEO guide.

How to add JSON-LD structured data in Next.js

The Next.js docs recommend rendering JSON-LD as a native <script> tag in a layout or page, not with next/script, and replacing < to prevent script injection. Inside the page component:

const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  datePublished: post.publishedAt,
  dateModified: post.updatedAt,
  author: { '@type': 'Person', name: post.authorName },
}

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

Google recommends JSON-LD, but markup makes a page eligible for rich results, not guaranteed to get them. Mark up only what is visible, then validate with the Rich Results Test and Schema Markup Validator. Our structured data guide lists the types worth adding.

Images, fonts and Core Web Vitals

Google's Core Web Vitals targets are LCP within 2.5 seconds, INP of 200 milliseconds or less and CLS of 0.1 or less, at the 75th percentile of page loads. The hero image and fonts matter most:

// app/hero.tsx
import Image from 'next/image'
import heroImage from './hero.jpg'

export function Hero() {
  return <Image src={heroImage} alt="Team planning a launch at a whiteboard" preload />
}
  • Fonts. Load them with next/font in the root layout: it self-hosts Google and local fonts, so browsers make no requests to Google, and adds a fallback font to reduce layout shift.
  • LCP image. Next.js 16 deprecated priority in favor of preload. Preload the one hero image that is your LCP element and keep it outside Suspense boundaries; if the LCP image changes by screen size, use loading="eager", because next/image lazy-loads by default.
  • Layout shift. Give images width and height (static imports do it for you), add sizes to responsive images, and make loading skeletons the same size as the content they replace.
  • Alt text. alt is required; use an empty string only for decorative images.

For real-user numbers, report metrics with useReportWebVitals from next/web-vitals in a small Client Component, then compare them with Search Console and PageSpeed Insights. More fixes are in our guide to speeding up a slow website.

hreflang and internationalization in Next.js

The App Router has no built-in locale routing. The documented pattern nests routes under app/[lang], redirects visitors without a locale in proxy.ts, prerenders each locale with generateStaticParams and sets <html lang> in the root layout; since Next.js 16.3, next/root-params lets any Server Component read lang. Then declare every language version:

// app/[lang]/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Props = { params: Promise<{ lang: string; slug: string }> }

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = await params

  return {
    alternates: {
      canonical: '/' + lang + '/blog/' + slug,
      languages: {
        'en-US': '/en-US/blog/' + slug,
        'de-DE': '/de-DE/blog/' + slug,
        'x-default': '/en-US/blog/' + slug,
      },
    },
  }
}

Google's rules: each version lists itself and all the others, links must point both ways or they are ignored, and URLs must be fully qualified, which metadataBase handles. Use x-default for the fallback. sitemap.ts can declare the same pairs with alternates.languages; Google treats the methods as equivalent, so one is enough.

Redirects and 404 pages in Next.js

Redirect moved URLs on the server. In next.config.ts, permanent: true returns a 308, which Google treats as a permanent move, like a 301:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async redirects() {
    return [
      { source: '/blog/:slug', destination: '/articles/:slug', permanent: true },
    ]
  },
}

export default nextConfig
  • Config redirects run before Proxy and before rendering, so they always send a real status code.
  • When a slug changes at runtime, call permanentRedirect(), which sends a 308. Plain redirect() sends a temporary 307.
  • For missing content, call notFound() and add a not-found.tsx with links back into the site. Next.js adds a noindex tag to 404 pages, so even a streamed 404 that returns 200 stays out of the index.

Moving an existing site to Next.js? Map every old URL first; our guide to migrating without losing rankings covers the process.

How to test Next.js SEO before and after launch

Start with the build output: next build marks each route ○ static, ● prerendered with generateStaticParams, ƒ rendered on demand, or ◐ partially prerendered with Cache Components. Then fetch pages the way a simple crawler does:

curl -s https://www.example.com/blog/my-post | grep -o '<link rel="canonical"[^>]*>'
curl -sI https://www.example.com/blog/old-slug

Check that:

  1. The title, description, canonical, hreflang, JSON-LD and main text are in the raw HTML.
  2. Moved URLs return 308 with the right location; missing URLs return 404.
  3. /robots.txt and /sitemap.xml work on production, not just locally.
  4. Search Console's URL Inspection tool shows the right rendered HTML, and the sitemap is submitted there.
  5. Key templates still pass the Rich Results Test and PageSpeed Insights after each release that changes routes.

Want a second pair of eyes before launch? Send us the URL and we will check what crawlers see. It is part of our web development work.

Next.js SEO FAQ

Is Next.js good for SEO?

Yes, when you use it as designed. The App Router renders pages to HTML on the server, the Metadata API builds titles, canonicals and social tags, and sitemap and robots files come from code. Most Next.js SEO problems come from content fetched only in the browser, missing canonicals or wrong status codes, not from the framework itself.

Does streaming hurt SEO in Next.js?

Not by itself. Streamed HTML is still rendered on the server, and the Next.js docs say Googlebot reads streamed metadata, while bots that cannot run JavaScript get metadata in the head. The real risk is the status code: once streaming starts the response is already a 200, so check that content exists before any Suspense boundary when you need a true 404.

Does Next.js add canonical tags automatically?

No. By default Next.js only adds the charset and viewport meta tags. You set the canonical yourself with alternates.canonical, usually inside generateMetadata for dynamic pages, and metadataBase turns relative paths into absolute URLs. Without a canonical, Google picks one for you, and it may not be the URL you want indexed.

How do I stop Google indexing my staging site?

Do not rely on robots.txt, because Google says it is not a way to keep pages out of search. Set a noindex rule through the robots field in your root layout metadata whenever the deployment is not production, or put staging behind a password. Then check production before launch so the noindex rule does not ship there by mistake.

What changed for SEO in Next.js 16?

The Metadata API works the same way, but several related details moved. Synchronous access to route params is gone, the props of opengraph-image and icon generators and the sitemap id are now Promises, middleware.ts is now proxy.ts, the image priority prop is deprecated in favor of preload, and AMP support was removed.

Sources

Need this done properly?

Apex Logic builds custom web apps, automation and AI features on a fixed quote, with 24 months of support and 100% code ownership.

Written by Ahtisham Arif

Apex Logic is a software studio in Multan, Pakistan that builds custom web apps, AI tools and automation for clients worldwide.

More from Ahtisham Arif

Comments

Loading comments...