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.
| Need | App Router file or field |
|---|---|
| Title and description | export const metadata or generateMetadata in page.tsx or layout.tsx |
| Base URL for relative links | metadataBase in app/layout.tsx |
| Canonical and hreflang | alternates.canonical and alternates.languages |
| sitemap.xml | app/sitemap.ts returning MetadataRoute.Sitemap |
| robots.txt | app/robots.ts returning MetadataRoute.Robots |
| Share image | opengraph-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.
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>
);
}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 },
};
}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 })),
];
}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",
};
}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 },
);
}| File | Types | Size limit |
|---|---|---|
| opengraph-image | .jpg, .jpeg, .png, .gif or .js, .ts, .tsx | 8 MB (build fails above it) |
| twitter-image | .jpg, .jpeg, .png, .gif or .js, .ts, .tsx | 5 MB (build fails above it) |
| opengraph-image.alt.txt | .txt | Becomes og:image:alt |
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.
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.
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.
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.
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.
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.
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.
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.