A Practical SEO Setup for the Next.js App Router
Metadata, canonical URLs, sitemaps, robots rules, structured data and Open Graph images in the Next.js App Router — implemented once, in a way that scales as you add pages.
Technical SEO is mostly plumbing: make every page reachable, make each one describe itself accurately, and make sure there is exactly one URL per piece of content. The Next.js App Router has first-class support for nearly all of it. This guide walks through a setup that you build once and then extend by adding content, rather than revisiting for each new page.
Make the content crawlable first
Nothing else matters if a page's content is not in the HTML. Render indexable content on the server. An article that appears only after a client-side fetch is, at best, indexed late and, at worst, indexed as "Loading...". Server Components, generateStaticParams and revalidation exist precisely so that article text is part of the first response.
You can check this directly: request the page without running JavaScript and look at the response.
curl -s https://example.com/blog/my-article | grep -o "<h1[^>]*>[^<]*"If the heading is there, crawlers can see it.
One metadata helper, used everywhere
The Metadata API lets each route export metadata or generateMetadata. The catch is that nested openGraph and twitter objects replace the parent's values instead of merging with them. A page that sets only openGraph.title silently loses the site name and image configured in the root layout.
The fix is a helper that always emits the full set:
// src/lib/seo.ts
import type { Metadata } from "next";
export function buildMetadata(p: {
title: string;
description: string;
path: string;
image?: string;
}): Metadata {
const image = p.image ?? "/images/og-default.jpg";
return {
title: p.title,
description: p.description,
alternates: { canonical: p.path },
openGraph: {
type: "website",
url: p.path,
title: p.title,
description: p.description,
images: [{ url: image, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: p.title,
description: p.description,
images: [image],
},
};
}Pair it with metadataBase in the root layout so relative paths resolve against the production domain:
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: { default: "Example", template: "%s | Example" },
};Write titles for people. A good title says what the page is, in under about 60 characters, without repeating the site name twice (the template adds it). Write descriptions that accurately summarise the page in a sentence or two; search engines may rewrite them, but a clear one is still your best input.
Canonical URLs: exactly one per page
A canonical URL tells search engines which address is the preferred one. Set it explicitly through alternates.canonical on every indexable page, using the production origin. Two pitfalls to watch for:
- Inherited canonicals. If the root layout sets
canonical: "/", any page that forgets to override it declares the homepage as its canonical. Always set one per page. - Preview domains. Hard-code the production domain through
metadataBaserather than reading it from a deployment URL variable, or previews will canonicalise to themselves.
A sitemap that maintains itself
app/sitemap.ts can be an async function, so it can read the same data layer your pages use. New content then appears automatically:
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/blog";
export const revalidate = 3600;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: "https://example.com/" },
{ url: "https://example.com/blog" },
...posts.map((p) => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: new Date(p.modified),
})),
];
}Use real modification dates. Stamping every URL with new Date() on each build tells crawlers that everything changed today, which trains them to ignore the field.
robots.txt: block less than you think
app/robots.ts should be short. Allow the site, disallow genuinely private or useless paths such as API routes, and point at the sitemap:
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/api/" },
sitemap: "https://example.com/sitemap.xml",
};
}Remember that robots.txt controls crawling, not indexing. To keep a page out of search results, use a noindex robots meta tag, and do not also block the page in robots.txt, or crawlers will never see the tag. Also avoid blocking paths that serve images used for social previews.
Structured data that matches the page
JSON-LD describes the page to machines. The rule is simple: mark up only what is visibly on the page, and only with types that fit. For a personal site and blog that usually means:
Person— once, site-wide, for the author, with name, URL, image and profile links.WebSite— once, site-wide.BlogPosting— on each article, with headline, dates and author.BreadcrumbList— on nested pages, matching the visible breadcrumb.
Render it as a script tag from a Server Component, and escape < so content can never close the tag early:
export default function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }}
/>
);
}Link entities with @id so an article's author points at the same Person defined once in the root layout, rather than redefining it on every page. Do not add ratings, reviews, FAQs or offers that do not exist on the page; that is a fast way to earn a manual action.
Social images
Open Graph images should be 1200×630. A static default covers most pages. For articles and projects, a route handler built on next/og can render a title card from the same data that renders the page, so a new article gets a matching image automatically:
import { ImageResponse } from "next/og";
export async function GET(_: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return new Response("Not found", { status: 404 });
return new ImageResponse(<div style={{ fontSize: 56 }}>{post.title}</div>, {
width: 1200,
height: 630,
});
}Images and performance
Search increasingly weighs user experience. Use next/image for content images so they are sized, lazy-loaded and served in modern formats; give meaningful images descriptive alt text and leave decorative ones with an empty alt; mark only the likely largest above-the-fold image as priority. Load fonts through next/font to avoid layout shift, and keep animations from hiding content before JavaScript runs.
What to verify before shipping
- Every indexable page has a unique title, description and canonical.
/sitemap.xmllists exactly the pages you want indexed, with the production domain./robots.txtdoes not block pages, articles or social images.- Structured data validates and matches visible content.
- Article text appears in the raw HTML.
- Pages you want hidden return
noindex, not just a robots block.
After that, SEO work is mostly publishing useful content and watching Search Console. For how the underlying routes and data layer are organised, see Structuring a Next.js App Router Project for Production, and for keeping pages light, Server vs Client Components in Next.js.
Related articles
- Structuring a Next.js App Router Project for Production
A practical layout for Next.js App Router projects — routes, data access, shared components and configuration — and the reasoning behind each boundary.
- Server vs Client Components in Next.js: Where to Draw the Boundary
How to decide what runs on the server and what ships to the browser in the Next.js App Router, with patterns for forms, interactivity and passing data across the boundary.
- One Backend, Two Clients: Architecture for a Next.js Web App and an Expo Mobile App
How to share an API, validation schemas and data-fetching patterns between a Next.js web app and an Expo / React Native app without duplicating business rules.