# Next.js App Router SEO Checklist for Next.js 15

> Next.js App Router SEO comes down to a short list: set metadataBase and a title template in the root layout, give every route a unique title, description and canonical, ship sitemap.ts and robots.ts, add JSON-LD, keep important pages static, size images properly and return real 404s. This checklist shows the Next.js 15 code for each item.

- Author: Sami Ullah (https://samiullah.tech/about)
- Published: 2026-11-11
- Updated: 2026-11-13
- Tags: nextjs, seo, performance
- Canonical: https://samiullah.tech/blog/nextjs-app-router-seo-checklist

The Next.js App Router gives you most of what search engines need out of the box: server-rendered HTML, a typed metadata API and file-based sitemaps. What it does not give you is the discipline to fill all of it in on every route. This post is the checklist I work through on every Next.js 15 project, in the order I do it, with the code I actually use.

Everything here was checked against Next.js 15 in November 2026. If you are still on the Pages Router, most of the ideas carry over, but the APIs are different.

## What does SEO mean in the Next.js App Router?

SEO in the App Router means making sure every route returns complete HTML with a unique title, description, canonical URL and structured data, fast enough to pass Core Web Vitals.

The App Router is a good base for this. Server Components render on the server by default, so the HTML a crawler receives already contains your headings and text. Google can run JavaScript, but other crawlers, including several AI search crawlers, often do not. Content that only appears after a client-side fetch is content you are betting on.

The second part is metadata. In the App Router, metadata is not a component you drop into the page. It is a `metadata` object or a `generateMetadata` function exported from `layout.tsx` or `page.tsx`. Next.js merges these from the root layout down to the page and writes the result into the head. The [official metadata docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) list every field.

## How do I set site-wide metadata in the root layout?

Set `metadataBase`, a title template, a default description and Open Graph defaults once in `app/layout.tsx`, so every page inherits them.

`metadataBase` is the setting people skip and later regret. Without it, relative URLs in `openGraph.images` or `alternates.canonical` cannot be turned into absolute URLs, and Next.js warns at build time. The title template saves you from typing the site name on every page.

```tsx
// app/layout.tsx

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Example Studio – Web Development",
    template: "%s | Example Studio",
  },
  description: "Web apps and websites built with Next.js, NestJS and PostgreSQL.",
  openGraph: {
    type: "website",
    siteName: "Example Studio",
    locale: "en_US",
  },
  twitter: { card: "summary_large_image" },
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
```

Two details matter here. The `lang` attribute on the html element helps both search engines and screen readers. And the `default` title is only used by routes that do not set their own title, which should be almost none.

## How do I give every page a unique title and description?

Export a static `metadata` object from fixed pages and a `generateMetadata` function from dynamic routes such as `/blog/[slug]`.

For a page like `/about`, a static object is enough:

```tsx
// app/about/page.tsx

export const metadata: Metadata = {
  title: "About",
  description: "Who we are, what we build and how to work with us.",
  alternates: { canonical: "/about" },
};

export default function AboutPage() {
  return <h1>About</h1>;
}
```

For dynamic routes, `generateMetadata` reads the route params and fetches the record. In Next.js 15, `params` is a Promise, so you must await it. I cover the patterns I use, including how to avoid fetching the same post twice, in [generateMetadata patterns in Next.js 15](/blog/nextjs-generatemetadata-patterns).

A good title is under about 60 characters and starts with the thing the page is about. A good description is 140 to 160 characters and reads like an answer, not a list of keywords. Google rewrites descriptions often, but a clear one still gets used more than a vague one.

## Do I need canonical URLs in Next.js?

Yes: set `alternates.canonical` on every indexable page so that query strings, trailing slashes and tracking parameters all point to one URL.

Next.js does not add a canonical tag for you. Without one, `/blog/post`, `/blog/post?utm_source=x` and `/blog/post/` can all be crawled as separate pages. Pick one form and stick to it. If you set `trailingSlash` in `next.config.ts`, make your canonicals and sitemap use the same form.

Filtered list pages are the most common trap. A `/work?category=vue` view is useful for people, but the canonical should stay `/work` unless the filtered page has its own unique content worth ranking.

## How do I generate sitemap.xml and robots.txt?

Create `app/sitemap.ts` and `app/robots.ts`; Next.js turns their return values into `/sitemap.xml` and `/robots.txt` at build time.

```ts
// app/sitemap.ts

const SITE = "https://example.com";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPublishedPosts(); // { slug: string; updatedAt: string }[]

  return [
    { url: SITE, lastModified: new Date("2026-11-01") },
    { url: `${SITE}/blog`, lastModified: new Date("2026-11-01") },
    ...posts.map((post) => ({
      url: `${SITE}/blog/${post.slug}`,
      lastModified: new Date(post.updatedAt),
    })),
  ];
}
```

```ts
// app/robots.ts

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }],
    sitemap: "https://example.com/sitemap.xml",
  };
}
```

Keep `lastModified` honest. If every URL gets `new Date()` on every build, the date carries no signal. Take it from your CMS, your frontmatter, or the last git commit for that file. The file conventions are documented under [sitemap.xml](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap) and [robots.txt](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots).

Do not block `/_next/static` in robots.txt. Google needs your CSS and JavaScript to render the page the way users see it.

## How do I add structured data?

Render a `script` tag with `type="application/ld+json"` from a Server Component, containing schema.org data such as `Organization`, `BlogPosting` and `BreadcrumbList`.

Next.js has no special API for JSON-LD, and it does not need one. You build a plain object, serialize it, and escape the less-than character so the content cannot break out of the script tag. I wrote a full guide with typed `@graph` examples in [JSON-LD structured data in Next.js](/blog/json-ld-structured-data-nextjs).

The rule that matters most: structured data must describe what is visible on the page. An FAQ in JSON-LD that is not on the page is a quality problem, not a ranking trick.

## Does the rendering mode affect SEO?

Rendering mode affects speed and freshness more than indexing, because static, ISR and dynamic pages all return full HTML to the crawler.

Where it matters is time to first byte and stability. A static page served from a CDN is fast everywhere. A dynamic page that waits on a slow database query can push Largest Contentful Paint over the 2.5 second threshold. My rule is to keep marketing pages, blog posts and case studies static or ISR, and only render per request when the page really depends on the request. The trade-offs are in [ISR vs SSG vs SSR in Next.js 15](/blog/isr-vs-ssg-vs-ssr-nextjs).

## How do I stop images from hurting Core Web Vitals?

Use `next/image` with correct `width`, `height` or `sizes`, and mark the largest above-the-fold image with `priority`.

Images are the most common cause of a slow LCP and of layout shift on content sites. `next/image` handles resizing, modern formats and lazy loading, but it cannot guess which image is your hero. Details and config are in [Next.js image optimization with next/image](/blog/nextjs-image-optimization).

## How should 404s, redirects and noindex work?

Call `notFound()` for missing records, use permanent redirects for moved URLs, and set `robots.index` to false on pages that must not be indexed.

A missing blog post should return a real 404 status, not a 200 page that says "not found". In a Server Component, `notFound()` from `next/navigation` does that and renders your `not-found.tsx`:

```tsx
// app/blog/[slug]/page.tsx

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
    </article>
  );
}
```

When a URL changes, add a redirect in `next.config.ts`. `permanent: true` sends a 308, which search engines treat like a 301:

```ts
// next.config.ts

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

export default nextConfig;
```

Preview and staging deployments should never be indexed. I add this to the root layout metadata so it cannot be forgotten:

```ts
// app/layout.tsx (merged into the metadata object shown earlier)

const isProduction = process.env.VERCEL_ENV === "production";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  robots: isProduction ? { index: true, follow: true } : { index: false, follow: false },
};
```

## What is the full checklist?

Here is the list I run before calling a Next.js site ready for search, with where each item lives.

| Item | Where it lives | How I check it |
| --- | --- | --- |
| `metadataBase` and title template | `app/layout.tsx` | No build warning, titles end with site name |
| Unique title and description | each `page.tsx` | Crawl with Screaming Frog or a script, no duplicates |
| Canonical URL | `alternates.canonical` | View source on three random pages |
| `lang` attribute | root layout | View source |
| sitemap.xml | `app/sitemap.ts` | Submit in Search Console, zero errors |
| robots.txt | `app/robots.ts` | Fetch `/robots.txt` on production |
| JSON-LD | Server Component script tag | Rich Results Test |
| Static where possible | route segment config | Build output symbols |
| Images sized, hero prioritized | `next/image` | Lighthouse LCP and CLS |
| Real 404 status | `notFound()` | `curl -I` on a bad slug |
| Redirects for moved URLs | `next.config.ts` | `curl -I` shows 308 |
| Previews noindexed | root layout `robots` | View source on a preview URL |

I used the same list when planning the [SaaS analytics dashboard concept](/work/saas-analytics-dashboard), where the public marketing pages need to rank but the app pages behind login should not be indexed at all. If you want something small to practise on, open a post in my [Markdown blog engine demo](/lab/markdown-blog-engine), a Next.js and MDX blog with sample posts, and run the view-source checks from this table against it. More of my Next.js work is collected on the [Next.js skill page](/skills/nextjs).

## What should you do first?

Open your production site, view the source of your three most important pages, and check four things: the title, the description, the canonical link and whether the main text is in the HTML. Fix whatever is missing in the root layout first, because one change there covers every route. Then add `app/sitemap.ts` with honest dates and submit it in Search Console. Those two steps take an afternoon and cover the most common SEO gaps on App Router sites.

## FAQ

### Is the Next.js App Router good for SEO?

Yes. The App Router renders Server Components to HTML on the server, so crawlers get full content without running JavaScript. It also has a typed Metadata API, file conventions for sitemap.xml and robots.txt, and static rendering by default. The risks come from your own choices, such as client-only content, missing canonicals or slow images, not from the router itself.

### Do I still need next/head in the App Router?

No. next/head only works in the Pages Router. In the App Router you export a metadata object or a generateMetadata function from a layout.tsx or page.tsx file, and Next.js writes the title, meta and link tags into the document head for you. Mixing the two approaches does nothing useful, so remove next/head when you migrate a page.

### How do I add a canonical URL in Next.js 15?

Set metadataBase once in the root layout, then add alternates.canonical in each page's metadata or generateMetadata return value. A relative path such as /blog/my-post is resolved against metadataBase into an absolute URL. Use the same canonical form everywhere: one protocol, one host, and one trailing slash policy, matching what your sitemap lists.

### Should I put every page in sitemap.xml?

Put every page you want indexed and nothing else. Leave out drafts, preview routes, filtered list views with query strings, and pages marked noindex. Give each entry an accurate lastModified date taken from your content, not the build time, because search engines learn to ignore dates that change on every deploy without the page changing.
