Next.js SEO Checklist (App Router)

In the App Router, SEO tags come from an exported metadata object or a generateMetadata function in layout and page files, which only work in Server Components. Set metadataBase once in the root layout, add app/sitemap.ts and app/robots.ts, give each route a canonical through alternates, and drop an opengraph-image file beside pages that need one.

Where each piece lives
NeedApp Router file or field
Title and descriptionexport const metadata or generateMetadata in page.tsx or layout.tsx
Base URL for relative linksmetadataBase in app/layout.tsx
Canonical and hreflangalternates.canonical and alternates.languages
sitemap.xmlapp/sitemap.ts returning MetadataRoute.Sitemap
robots.txtapp/robots.ts returning MetadataRoute.Robots
Share imageopengraph-image.png, .jpg, .gif or .tsx in the route folder

Updated

The App Router has built-in answers for almost every technical SEO task, but they are spread across a dozen documentation pages. This checklist gathers them in order, with copyable code for each item, and the tick boxes let you track your own project as you go.

The checklist

Code for each item

Root layout: metadataBase, title template, defaults

Set once in app/layout.tsx. Child pages that export title: "Pricing" render as Pricing | Northside.

import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://northside.example"),
  title: { default: "Northside Roofing", template: "%s | Northside" },
  description: "Roof repairs and replacements across Leeds.",
  openGraph: { siteName: "Northside Roofing", type: "website", locale: "en_GB" },
};

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

Dynamic route: generateMetadata with canonical

params is awaited. getPost stands for your own data function.

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

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.summary,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: { type: "article", title: post.title, publishedTime: post.date },
  };
}

app/sitemap.ts

Returns one entry per URL. changeFrequency and priority are optional and Google ignores them, so they are left out.

import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/posts";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const base = "https://northside.example";
  const posts = await getAllPosts();
  return [
    { url: base, lastModified: new Date("2026-10-01") },
    { url: `${base}/pricing` },
    ...posts.map((p) => ({ url: `${base}/blog/${p.slug}`, lastModified: p.updatedAt })),
  ];
}

app/robots.ts

Generates robots.txt at the site root.

import type { MetadataRoute } from "next";

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

app/blog/[slug]/opengraph-image.tsx

Draws a 1200 by 630 share image from the post title with ImageResponse from next/og. The alt, size and contentType exports become og tags.

import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";

export const alt = "Northside Roofing blog";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return new ImageResponse(
    (
      <div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center",
                    padding: 80, background: "#0f172a", color: "white", fontSize: 64 }}>
        {post?.title ?? "Northside Roofing"}
      </div>
    ),
    { ...size },
  );
}

Image file limits

File-based share images
FileTypesSize limit
opengraph-image.jpg, .jpeg, .png, .gif or .js, .ts, .tsx8 MB (build fails above it)
twitter-image.jpg, .jpeg, .png, .gif or .js, .ts, .tsx5 MB (build fails above it)
opengraph-image.alt.txt.txtBecomes og:image:alt

Static first, dynamic where needed

Most marketing pages can use the static metadata object. Reach for generateMetadata on dynamic routes such as /blog/[slug] or /products/[id], where the title comes from data.

fetch calls inside generateMetadata are memoized with the same request in the page, so loading the post for metadata and again for rendering does not double the work.

Rendering still matters

Server Components send real HTML, which is what crawlers and link previewers read first. Content that only appears after a client-side fetch is slower to discover and invisible to apps that do not run JavaScript.

Since version 15.2, metadata from generateMetadata can stream after the first HTML for browsers. For bots that cannot run JavaScript, such as facebookexternalhit, Next.js still puts the tags in the head, detected by user agent.

How it works

  1. Work down the checklist and tick each item once it is in place. Ticks stay in this tab only.
  2. Copy the code for the root layout first, since metadataBase and the title template affect every other route.
  3. Add the sitemap and robots files, then visit /sitemap.xml and /robots.txt on your dev server to read the output.
  4. View the page source of a few routes to confirm the title, canonical and og tags are in the HTML.

Tips

Frequently asked questions

Can I use generateMetadata in a client component?

No. The metadata object and generateMetadata are only supported in Server Components, because Next.js has to resolve them on the server before rendering. Keep the page as a Server Component and import a client component for the interactive parts.

Why is params a Promise in generateMetadata?

Current versions of Next.js pass params and searchParams as Promises, so you write const { slug } = await params inside an async generateMetadata. Code written for older versions that reads params.slug directly needs updating.

How do I make a sitemap with more than 50,000 URLs in Next.js?

Use generateSitemaps in a sitemap.ts file. It returns a list of ids, and the default export builds one sitemap per id. In Next.js 16 the id arrives as a Promise. The files are served at paths like /product/sitemap/1.xml.

Does Next.js add a canonical tag automatically?

Not unless you set one. Add alternates.canonical in the metadata for each route, usually as a relative path such as /blog/my-post, and let metadataBase turn it into a full URL.

Can I export both metadata and generateMetadata?

No, a route segment can export one or the other. Use the static object when nothing depends on the request or fetched data, and generateMetadata when it does.

Related: Next.js Image component, Sitemap and robots.txt examples, Open Graph tags explained, Hreflang tags explained, Next.js middleware examples, Core Web Vitals guide.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap