Most Next.js SEO advice stops at "use the Metadata API". That covers the title and the description, which are the two things least likely to be wrong. The problems that actually cost traffic are quieter: a canonical that disagrees with the host, a sitemap that stopped matching reality three releases ago, thin pages diluting the index, structured data that validates but describes nothing.
This is the checklist I work through on a Next.js App Router project, in the order I work through it.
1. Settle the canonical host first
Every absolute URL on the site — canonical tags, Open Graph URLs, the sitemap, RSS, JSON-LD identifiers — has to agree on one host. The most common Next.js SEO bug is a canonical tag pointing at the apex domain on a site served from www, where the apex then 301s back to www. Google is told "this page's real address is over there", follows it, and is redirected back. The signal is contradictory, and ranking suffers quietly.
One constant, used everywhere:
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.example.com";Then set metadataBase from that same value so every relative URL Next resolves inherits it:
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: { default: "Site name", template: "%s | Site name" },
};With metadataBase set, a page can specify alternates: { canonical: "/about" } and get a correct absolute URL without repeating the host.
2. Build the head in one helper, not per page
Page-level metadata objects merge shallowly. Define openGraph on a page and it replaces the layout's openGraph wholesale — including the siteName and the image you thought were inherited. Sites discover this when half their pages share one preview card and the other half have none.
Build the whole set in one place and call it from every page:
export function pageMeta({ title, description, path, type = "website" }: PageMetaArgs): Metadata {
return {
title,
description,
alternates: { canonical: path },
openGraph: {
type, url: path, siteName: site.name, locale: "en_IN",
title, description,
images: [{ url: `${path}/opengraph-image`, width: 1200, height: 630 }],
},
twitter: { card: "summary_large_image", title, description },
};
}Two rules keep the output useful: titles at 50–60 characters and descriptions at 150–160. Both get truncated beyond that, and the truncation usually lands mid-phrase on the part that was doing the persuading. Keep the keyword in the first half, where it survives.
3. Generate the sitemap and robots.txt as code
A hand-written public/sitemap.xml is accurate on the day you write it. In the App Router both are route files, generated from the same source your pages read:
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts();
const pages = ["", "/blog", "/about"].map((p) => ({ url: `${SITE_URL}${p}` }));
return [...pages, ...posts.map((p) => ({ url: `${SITE_URL}/blog/${p.slug}`, lastModified: p.date }))];
}lastModified is the part worth caring about. Without it, crawlers decide for themselves how often to come back. With it, a genuine update is a signal rather than a guess — provided it reflects real edits, not a build timestamp. A lastmod that changes on every deploy teaches crawlers to ignore it.
robots.ts is three lines and should point at the sitemap:
export default function robots(): MetadataRoute.Robots {
return { rules: { userAgent: "*", allow: "/" }, sitemap: `${SITE_URL}/sitemap.xml` };
}4. Keep thin pages out of the index
Most content sites generate pages nobody searches for: tag archives, paginated listings, filter permutations. They are useful for crawling and useless as results, and in volume they dilute the crawl budget spent on pages that matter.
Mark them accordingly:
export async function generateMetadata({ params }): Promise<Metadata> {
return { ...pageMeta({ ... }), robots: { index: false, follow: true } };
}follow matters as much as index: false — crawlers should still traverse the links on those pages to reach the real content. Leave the same pages out of the sitemap, since a sitemap entry for a noindex URL is a contradiction you are asking Google to resolve.
Routes that disappear need the same discipline. When a section is removed, 301 it rather than letting it 404:
async redirects() {
return [
{ source: "/work/:slug", destination: "/blog", permanent: true },
{ source: "/resume", destination: "/about", permanent: true },
];
}5. Write structured data as one graph
Scattered JSON-LD blocks that each re-declare the author produce several entities Google has to reconcile. Define the shared nodes once with an @id, and reference them:
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Person", "@id": `${SITE_URL}/#person`, name: site.name, jobTitle: site.role },
{ "@type": "WebSite", "@id": `${SITE_URL}/#website`, publisher: { "@id": `${SITE_URL}/#person` } },
],
}Then every article points at the same person:
{
"@type": "BlogPosting",
headline: post.title,
datePublished: post.date,
dateModified: post.updated,
author: { "@id": `${SITE_URL}/#person` },
publisher: { "@id": `${SITE_URL}/#person` },
}dateModified earns its place: it is how a refreshed post is distinguished from a stale one. Add BreadcrumbList on every page and FAQPage where a post genuinely answers questions — and skip HowTo, which Google retired as a rich result, so the markup renders nothing.
If you want the same content to surface in AI answers rather than only in search results, structured data is half the job and an llms.txt is the other half — I covered that in llms.txt and GEO.
6. Treat Core Web Vitals as part of this list
Technical SEO and performance stop being separate concerns at the point where a slow page is also a page that ranks lower. Three things carry most of the weight on a Next.js site:
next/imagewith explicit dimensions, andpriorityon the one above-the-fold image, so it is not lazy-loaded and does not shift the layout.next/font, which self-hosts and preloads rather than round-tripping to a font CDN before text can paint.- Static rendering wherever the content allows it, so HTML comes from a CDN instead of being rendered per request.
The mechanics of each are in my Next.js performance guide, and the font and render-blocking work specifically in how I cut First Contentful Paint.
[EXTERNAL LINK: Google Search Central documentation on canonical URLs and consolidation signals] [EXTERNAL LINK: web.dev guidance on Core Web Vitals thresholds]
7. Verify in CI, not in Search Console
Search Console reports a regression weeks after it shipped, which makes it an archive rather than a safety net. Everything above is machine-checkable against a built site:
- Exactly one
<h1>per page, no skipped heading levels - Title ≤ 60 characters, description 150–160, both unique across pages
rel="canonical"present, absolute, and matching the served host- Every sitemap URL returns 200, and every indexable page is in the sitemap
- No internal link returns a non-200 status
- JSON-LD parses and contains the types the page should emit
A short crawler against next start in CI catches all of it before merge. That is the same instinct behind building a crawler that thinks like Googlebot: the point is not a score, it is knowing precisely what a crawler sees.
The takeaway
Technical SEO on a Next.js site is mostly a question of single sources of truth: one host constant, one metadata helper, one generated sitemap, one entity graph. Nearly every bug in this list is some version of two places disagreeing about the same fact — the canonical and the server, the sitemap and the routes, the JSON-LD and the page.
Fix the duplication and most of the checklist holds itself up. Then put the checks in CI so it stays that way.