Next.js gives you almost everything SEO needs out of the box — and almost none of it is on by default. This is the App Router checklist: what to add, where it goes, and the mistakes that quietly cost you indexing.
All examples use the App Router (app/), Next.js 13.2+.
1. Set metadataBase once
Open Graph and canonical URLs must be absolute. metadataBase lets you write relative paths everywhere else and have Next.js resolve them:
// app/layout.tsx
export const metadata = {
metadataBase: new URL("https://example.com"),
title: {
default: "iFace — Frontend Blog for Beginners",
template: "%s — iFace",
},
description:
"Learn HTML, CSS and JavaScript and prepare for frontend technical interviews.",
};
The template is the useful part: any child page that sets title: "Core Web Vitals" renders as Core Web Vitals — iFace, and you never repeat the brand suffix by hand.
Without metadataBase, Next.js falls back to localhost:3000 in development and logs a warning — and your OG images break in production.
2. Static metadata for static pages
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description: "Who writes iFace and why.",
alternates: { canonical: "/about" },
openGraph: {
title: "About iFace",
description: "Who writes iFace and why.",
type: "website",
},
};
alternates.canonical is the piece most projects forget. Resolved against metadataBase, "/about" becomes https://example.com/about.
3. generateMetadata for dynamic routes
For anything with a [slug], metadata has to be derived from the content:
// app/post/[slug]/page.tsx
import type { Metadata } from "next";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPostContent(slug);
if (!post) {
return { title: "Post not found" };
}
return {
title: post.data.title,
description: post.data.description,
alternates: { canonical: `/post/${slug}` },
openGraph: {
title: post.data.title,
description: post.data.description,
type: "article",
url: `/post/${slug}`,
},
};
}
Two things to notice:
- In Next.js 15
paramsis a Promise and must be awaited. Forgetting this is the most common upgrade bug. - Don't worry about the duplicate fetch. Next.js dedupes identical requests inside a render pass, so calling the same loader in
generateMetadataand in the page component costs you one fetch, not two.
4. Pre-render your routes
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Every returned param becomes a statically generated HTML file at build time. That is the fastest possible response and guarantees the crawler receives complete markup on the first pass — no JavaScript execution required.
5. sitemap.ts
Next.js generates /sitemap.xml from a single file — no library, no build script:
// app/sitemap.ts
import type { MetadataRoute } from "next";
const BASE_URL = "https://example.com";
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts().map((post) => ({
url: `${BASE_URL}/post/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.7,
}));
return [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
...posts,
];
}
Keep it honest: lastModified should reflect a real content change. A sitemap that claims every page changed today teaches search engines to ignore the field.
6. robots.ts
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/"],
},
sitemap: "https://example.com/sitemap.xml",
};
}
If you need a page crawled but not indexed, that is not a robots.txt job — blocking a URL prevents the crawler from ever seeing the noindex tag. Use metadata instead:
export const metadata = {
robots: { index: false, follow: true },
};
7. OG images
Drop an opengraph-image.tsx next to a route and Next.js generates the image at the edge:
// app/post/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: { slug: string } }) {
const post = getPostContent(params.slug);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#1a1a2e",
color: "white",
fontSize: 64,
padding: 80,
textAlign: "center",
}}
>
{post.data.title}
</div>
),
size
);
}
A static opengraph-image.png in the same folder works too, and costs nothing at runtime.
8. JSON-LD
There is no metadata API for structured data — you render the script tag yourself, in a Server Component:
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = getPostContent(slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.data.title,
description: post.data.description,
datePublished: post.data.date,
author: { "@type": "Person", name: "iFace" },
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* ...content... */}
</article>
);
}
JSON.stringify on an object you control is the sane way to do this. Never interpolate raw user input into that string.
9. Images and fonts
import Image from "next/image";
<Image
src="/hero.png"
alt="Lighthouse report showing a perfect performance score"
width={1200}
height={630}
priority // above the fold: preloads, skips lazy loading
/>
next/image handles format negotiation, responsive srcset and the intrinsic sizing that prevents layout shift. The one flag you have to set yourself is priority on your LCP image.
For fonts, next/font self-hosts the files and inlines the CSS, which removes a render-blocking request to Google Fonts and eliminates the font-swap layout shift:
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"], display: "swap" });
10. Traps that silently break indexing
"use client"at the top of a page file. Metadata exports are ignored in Client Components — the page ships with no title.- Content behind
useEffect. It will not be in the initial HTML. Fetch in the Server Component instead. - Trailing slash inconsistency.
/post/xand/post/x/serving the same content without a canonical is duplicate content. Pick one viatrailingSlashinnext.config.js. - Redirect chains.
http → https → www → pathcosts crawl budget. Collapse them to one hop. - A missing 404. A route that returns 200 with an empty page gets indexed as thin content. Call
notFound().
Verifying it
npm run build && npm start
curl -s http://localhost:3000/post/your-slug | grep -i "<title>"
curl -s http://localhost:3000/sitemap.xml | head
curl -s http://localhost:3000/robots.txt
If the title is there in raw curl output, the crawler sees it too.
After deploying, submit the sitemap in Google Search Console and use URL Inspection to see the rendered HTML Google actually stored. For an ongoing view — crawling every route, catching the page where someone forgot generateMetadata, comparing your coverage against competing sites — an AI SEO tool like RankBuddy automates that loop: it audits the site, connects to Search Console, flags missing schema and metadata, and re-runs on a schedule so a regression shows up in days instead of at the next quarterly review.
The checklist
-
metadataBase+ title template in the root layout -
titleanddescriptionon every route -
alternates.canonicalon every route -
generateMetadataawaitsparamson dynamic routes -
generateStaticParamsfor known slugs -
app/sitemap.tswith honestlastModified -
app/robots.tspointing at the sitemap - OG image, 1200×630
- JSON-LD matching the page type
-
priorityon the LCP image,next/fontfor fonts - No
"use client"on page files that need metadata -
notFound()for missing content
Related reading: SEO for frontend developers for the framework-agnostic fundamentals, and Core Web Vitals explained for the performance half.