Next.js gives you good App Router SEO defaults and several ways to quietly undo them. This is the checklist we work through on every build, in the order that catches problems earliest.
1. Metadata
Use the App Router's Metadata API rather than hand-writing tags. Static metadata for fixed pages:
// app/services/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Web Development Services",
description: "Custom Next.js applications, built for speed and measured on conversion.",
alternates: { canonical: "/services" },
openGraph: {
title: "Web Development Services",
description: "Custom Next.js applications, built for speed and measured on conversion.",
url: "/services",
type: "website",
},
};Dynamic pages use generateMetadata, which runs on the server and can fetch:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return { title: "Not found" };
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: { images: [post.coverImage] },
};
}Set metadataBase once in the root layout so relative URLs resolve to absolute ones in production:
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: { default: "Example", template: "%s | Example" },
};Without metadataBase, Open Graph image URLs stay relative and social platforms fail to fetch them — a silent failure that only shows up when someone shares a link.
Check for: duplicate titles across pages, descriptions over about 160 characters, and — most commonly — descriptions generated by truncating the first paragraph at a fixed character count. slice(0, 160) cuts words in half. Trim at a word boundary.
2. Canonicals and trailing slashes
Pick one URL form and enforce it everywhere. If trailingSlash: true is set in next.config.ts, every canonical, every sitemap entry, and every internal link must include the trailing slash. Mixing forms creates two URLs for one page, splits your signals, and wastes crawl budget.
Verify all three agree:
- The canonical tag on the page
- The URL in
sitemap.xml - The
hrefin your internal links
A mismatch between these is one of the most common technical SEO faults we find, and it's invisible without checking deliberately.
3. Sitemap and robots
Generate both from code so they cannot drift:
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: "https://example.com/", lastModified: new Date(), priority: 1 },
...posts.map((p) => ({
url: `https://example.com/blog/${p.slug}/`,
lastModified: p.updatedAt,
})),
];
}// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: "https://example.com/sitemap.xml",
};
}Only include pages you want indexed. A sitemap containing redirects, 404s, or noindex pages sends conflicting signals. If you remove pages, regenerate the sitemap in the same commit — a stale sitemap advertising deleted URLs is a recurring self-inflicted problem.
Use lastModified honestly. Setting it to new Date() on every build tells crawlers everything changed on every deploy, and they learn to ignore it.
4. Structured data that matches the page
JSON-LD is worth adding, with one rule: it must agree with what's visible.
The most common mismatch we find is authorship. The byline says a person; the schema says Organization. That inconsistency undercuts exactly the E-E-A-T signal the markup was added to build.
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person", // matches the visible byline
name: "Mehar Farhan",
url: "https://example.com/about/",
},
};
// In the component:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>Use JSON.stringify, never string interpolation. Building JSON-LD by hand in a template literal breaks the moment a title contains a quote or an ampersand — and it fails silently. Google drops invalid structured data without telling you. If you're generating pages in a build script, validate every emitted block by parsing it:
try { JSON.parse(block); } catch (e) { throw new Error(`Invalid JSON-LD: ${e.message}`); }That single check would have caught every structured-data bug we've encountered in static build pipelines.
Also: datePublished and dateModified must be real. A hardcoded date shared across every article is worse than omitting them.
5. Rendering — verify what crawlers receive
Next.js can render server-side, but "use client" components that fetch data in useEffect produce pages whose content isn't in the initial HTML. Google can execute JavaScript, but it's slower, less reliable, and other crawlers — including several AI search systems — are far less capable.
Check directly:
curl -s https://example.com/your-page/ | grep "some text you expect"If your main content isn't there, it isn't reliably indexable. Move that fetch to a Server Component.
Also confirm your pages are actually static or dynamic as intended. next build prints a route table marking each ○ (static) or ƒ (dynamic). A page you expected to be prerendered showing as dynamic usually means something called cookies() or headers() higher in the tree.
6. Core Web Vitals
The framework gives you the tools; you still have to use them.
next/imagefor every image — modern formats, correct sizing, and reserved space that prevents layout shift. Setpriorityon your LCP image.next/fontto self-host and preload. Eliminates the layout shift from swapping webfonts.- Explicit dimensions on everything that loads late — embeds, ads, dynamic banners.
- Check your bundle.
@next/bundle-analyzerwill show you the 200KB date library imported for one function call.
Measure with field data, not just lab scores. PageSpeed Insights shows both; the field data is what Google uses.
7. The build-pipeline bugs nobody checks
If you generate pages in a script rather than at runtime, a single templating bug affects every page at once — and these are invisible unless you look at the output.
Worth verifying on generated HTML:
- Escaping. Are titles with
&or"escaped in<title>,<meta content="...">, and JSON-LD? Each needs different escaping. - Is anything hardcoded that shouldn't be? Publish dates, reading times, author names. A "5 min read" label on every page including the 90-word ones is a small dishonesty search engines and readers both notice.
- Is your markdown renderer producing valid HTML? We have seen paragraph tags injected inside
<pre><code>blocks, and markdown tables rendered as literal pipe characters — across dozens of pages, entirely unnoticed, because nobody read the generated source.
Add assertions to the build. Fail it when a page has no <title>, no meta description, an unparseable JSON-LD block, or a word count below a threshold you consider publishable. Catching this at build time costs an hour; catching it in Search Console costs months.
The short version
- Metadata API, with
metadataBaseset - One canonical URL form, enforced in tags, sitemap, and links
- Generated sitemap and robots, containing only indexable pages
- JSON-LD via
JSON.stringify, matching the visible page - Main content present in the server-rendered HTML
next/image,next/font, explicit dimensions- Build-time assertions so template bugs can't ship silently
Want this done properly?
We build Next.js sites with the technical SEO foundation handled at the pipeline level, so it can't quietly regress. Talk to us about web development.