Back to the blog

Technical SEO for the Next.js App Router: A Practical Checklist

Technical SEO for the Next.js App Router: A Practical Checklist

9 October 2026 — SEO

Published

9 October 2026

Reading time

4 min read

Category

SEO

Tags

  • SEO
  • Next.js
  • App Router
  • Metadata
  • Structured Data

(Intro)Metadata, canonical URLs, sitemaps, robots.txt, structured data, hreflang and Core Web Vitals in the Next.js App Router, with code you can copy.

Next.js gives you everything you need for technical SEO, but almost none of it is on by default. This is the checklist I go through on every App Router project, roughly in order of impact.

1. Server-render the content that matters

Search engines can run JavaScript, but they index server-rendered HTML faster and more reliably. In the App Router, Server Components are the default — keep your headings, body copy and links in them, and use Client Components for interaction, not for content.

A quick test: open your page with JavaScript disabled, or run curl https://yoursite.com/page. If the main text isn't in the response, neither is it in the first pass of indexing.

2. Set metadataBase and a title template

In the root layout:

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

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    default: 'Example Studio — Web Design and Development',
    template: '%s — Example Studio',
  },
  description: 'Websites and products for small teams: design, development and launch.',
  openGraph: { siteName: 'Example Studio', type: 'website' },
}

metadataBase turns relative URLs (canonical, Open Graph images) into absolute ones. The template means child pages only set their own part of the title.

3. Unique titles and descriptions per page

Every indexable page needs its own <title> (around 50–60 characters, the keyword near the start) and meta description (around 150–160 characters, written to be clicked). For dynamic pages use generateMetadata:

app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return {}
  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: 'article',
      publishedTime: post.date,
      images: [{ url: post.cover, width: 1200, height: 630 }],
    },
  }
}

4. Canonical URLs

Set alternates.canonical on every page. It protects you from duplicates created by query strings (?utm_source=...), trailing slashes and alternative domains. Pick one form — with or without www, with or without trailing slash — and redirect the others to it at the host.

5. A sitemap

app/sitemap.ts generates /sitemap.xml, including on a static export:

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

export const dynamic = 'force-static'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = 'https://example.com'
  const posts = await getAllPosts()
  return [
    { url: base, changeFrequency: 'monthly', priority: 1 },
    ...posts.map((post) => ({
      url: `${base}/blog/${post.slug}`,
      lastModified: post.date,
      changeFrequency: 'yearly' as const,
      priority: 0.7,
    })),
  ]
}

Submit it once in Google Search Console and Bing Webmaster Tools.

6. robots.txt

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

export const dynamic = 'force-static'

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

Don't use robots.txt to hide pages from the index — a blocked page can still be indexed from links. Use robots: { index: false } in the page's metadata instead.

7. Structured data (JSON-LD)

Structured data helps search engines understand what a page is. For an article, render a script tag in the page:

tsx
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  description: post.excerpt,
  datePublished: post.date,
  image: `https://example.com${post.cover}`,
  author: { '@type': 'Person', name: 'Jane Doe', url: 'https://example.com' },
}

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

Useful types for small businesses: Organization or Person on the home page, BlogPosting on articles, BreadcrumbList on deep pages, Product on shop pages. Validate them with Google's Rich Results Test.

8. Open Graph images

Links shared on LinkedIn, X, Slack and messaging apps use Open Graph tags. Give every page an image of 1200×630 pixels. You can add a static opengraph-image.png next to a page, or generate one with ImageResponse in opengraph-image.tsx (generated at build time on a static export, so it can't depend on the request).

9. Multilingual sites: real URLs and hreflang

Each language needs its own URL, or search engines will only ever see one of them. Switching languages on the client with localStorage is fine for visitors but invisible to Google. Use a locale segment — app/[lang]/... — and declare the alternatives:

ts
alternates: {
  canonical: '/en/blog/my-post',
  languages: {
    en: '/en/blog/my-post',
    'zh-CN': '/zh/blog/my-post',
    'sr-Latn': '/sr/blog/my-post',
    'x-default': '/en/blog/my-post',
  },
}

Every language version should list all the others, including itself.

  • One <h1> per page, then <h2>/<h3> in order. Headings are an outline, not a font size.
  • Link related pages to each other with descriptive anchor text ("static export guide", not "click here"). Use <Link> so the links are real <a href> elements.
  • Keep important pages within three clicks of the home page.

11. Core Web Vitals

Speed is a ranking signal, and more importantly a conversion signal. Measure with PageSpeed Insights (field data comes from real Chrome users) and aim for LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1. The usual App Router culprits: an oversized hero image, too much client-side JavaScript, and layout shift from fonts or images without dimensions. If your site is animation-heavy, see Core Web Vitals for animated websites.

12. Images

  • Descriptive alt text on content images; empty alt="" on decorative ones.
  • Explicit width/height or an aspect-ratio to prevent layout shift.
  • Modern formats (AVIF, WebP) and sensible sizes.
  • Descriptive file names: nextjs-sitemap-example.png, not IMG_2041.png.

13. 404s and redirects

Return a real 404 for missing pages (notFound() in the App Router). When you move or rename pages, add a 301 redirect from the old URL — on a static export, that's a host-level _redirects rule.

The short version

  • Content in Server Components
  • metadataBase and a title template in the root layout
  • Unique title, description and canonical on every page
  • sitemap.ts and robots.ts, sitemap submitted
  • JSON-LD for articles, organization, breadcrumbs
  • Open Graph image on every page
  • One URL per language, with hreflang
  • Logical headings and descriptive internal links
  • Core Web Vitals in the green
  • Real 404s and 301s for moved pages

Building a static site? Read the Next.js static export guide for the parts that work differently without a server.

Newsletter

Get new posts by email

A short email when something new is up. No spam, and you can unsubscribe at any time.

Build a Markdown Blog in Next.js Without a CMS or Database
Build a Markdown Blog in Next.js Without a CMS or Database
Next post →