
Next.js gives you all the building blocks for excellent SEO—SSR/SSG for crawlability, the Metadata API for rich previews, and powerful image and performance tooling to nail Core Web Vitals. In this post, you'll wire up the essentials and avoid the common gotchas.
Why Next.js for SEO?
- Pre-rendered HTML (SSG/SSR) → better crawlability & faster first paint
- App Router Metadata API → clean titles, canonical URLs, Open Graph/Twitter cards
- Built-in Image Optimization → faster LCP with responsive, lazy-loaded images
- Edge & caching controls → keep pages fresh without sacrificing speed
1) Metadata API: Titles, Canonicals, and Previews
Set site-wide defaults in app/layout.tsx
, then override per page. Canonicals help prevent duplicate content issues; Open Graph/Twitter drive high-quality link previews.
// app/layout.tsx
import type { Metadata } from "next";
const baseUrl = "https://example.com";
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
default: "Example — Build fast with Next.js",
template: "%s · Example",
},
description: "Example site built with Next.js, optimized for SEO and speed.",
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
url: baseUrl,
title: "Example — Build fast with Next.js",
description: "Performance-first Next.js starter.",
images: ["/og-default.png"],
},
twitter: {
card: "summary_large_image",
creator: "@example",
},
};
Per-page overrides with generateMetadata
:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/posts";
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
const title = post.seoTitle ?? post.title;
const url = `/blog/${post.slug}`;
const image = post.coverImage ?? "/og-default.png";
return {
title,
description: post.description,
alternates: { canonical: url },
openGraph: {
type: "article",
url,
title,
description: post.description,
publishedTime: post.date,
authors: post.author ? [post.author] : undefined,
images: [image],
},
twitter: {
card: "summary_large_image",
title,
description: post.description,
images: [image],
},
robots: { index: true, follow: true },
};
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return <article className="prose">{/* MDX content... */}</article>;
}
Tip: For paginated/filtered lists, set canonicals to the base page and use
rel="next"
/rel="prev"
via<link>
ingenerateMetadata
if you implement deep pagination.
2) Robots.txt & Sitemap without plugins
Generate both with first-party file conventions.
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/" },
// Disallow private paths:
{ userAgent: "*", disallow: ["/api/", "/admin/"] },
],
sitemap: "https://example.com/sitemap.xml",
};
}
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPostSlugs } from "@/lib/posts";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://example.com";
const slugs = await getAllPostSlugs();
const posts = slugs.map((slug) => ({
url: `${baseUrl}/blog/${slug}`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.7,
}));
return [
{ url: `${baseUrl}/`, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
...posts,
];
}
Large sites? Consider a generator task or chunked sitemaps to keep files small and fresh.
3) JSON-LD Structured Data (Articles, Breadcrumbs, etc.)
Search engines love structured data. For blog posts, use the Article
schema. Add a tiny client component to inject JSON-LD safely.
// components/JsonLd.tsx
"use client";
import Script from "next/script";
export default function JsonLd({ id, data }: { id: string; data: any }) {
return (
<Script
id={id}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
// app/blog/[slug]/page.tsx (inside the component render)
import JsonLd from "@/components/JsonLd";
const schema = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.description,
datePublished: post.date,
author: post.author ? [{ "@type": "Person", name: post.author }] : undefined,
image: post.coverImage ? [`https://example.com${post.coverImage}`] : undefined,
mainEntityOfPage: `https://example.com/blog/${post.slug}`,
};
return (
<article className="prose">
{/* ...post content... */}
<JsonLd id="blogpost-jsonld" data={schema} />
</article>
);
Add BreadcrumbList on article pages for better sitelinks.
4) Images that Rank (and Pass LCP)
Use <Image />
everywhere: responsive sizes, alt text, and priority for above-the-fold media.
import Image from "next/image";
export default function Cover({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
className="rounded-xl"
/>
);
}
Checklist:
- Descriptive
alt
text (not keyword stuffing) - Correct
width/height
to avoid CLS - Use AVIF/WebP assets when possible
- Mark first, largest image
priority
to help LCP
5) International & Multi-locale SEO (optional)
Expose hreflang
via alternates.languages
:
// app/layout.tsx (metadata)
alternates: {
canonical: "/",
languages: {
"en-US": "/en",
"fr-FR": "/fr",
},
},
Make sure your routing mirrors locales (/en/...
), and your content is actually localized (not just translated metadata).
6) Freshness with ISR, Caching, and Revalidation
Keep static HTML fast without going stale.
// app/blog/[slug]/page.tsx
export const revalidate = 60 * 60; // re-generate at most once per hour
// If you have tag-based updates somewhere else, trigger:
// import { revalidateTag } from "next/cache";
// revalidateTag("posts");
Use fetch(..., { next: { revalidate: 3600, tags: ["posts"] } })
on data requests to align page/data lifecycles.
7) Dynamic OG Images (big CTR win)
Auto-generate unique preview images per post for social/Slack cards.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OG({ params }: { params: { slug: string } }) {
// you could load post data here
const title = decodeURIComponent(params.slug).replace(/-/g, " ");
return new ImageResponse(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
fontSize: 72,
fontWeight: 800,
}}
>
{title}
</div>
),
{ ...size }
);
}
8) Content & Internal Linking (low effort, high impact)
- One primary topic per page; use clear
<h1>
and logical heading hierarchy. - Link related posts with
<a>
/<Link>
using descriptive anchor text. - Keep URL slugs short, readable, and stable (
/blog/boost-seo-with-nextjs
).
9) Analytics & Monitoring
- Track Core Web Vitals (LCP, CLS, INP) and fix regressions early.
- Add analytics with
next/script
and astrategy
of"afterInteractive"
to avoid blocking rendering.
10) Common Pitfalls to Avoid
- Missing canonical on filtered/paginated pages → duplicate content
- Huge images without defined dimensions → CLS tanks rankings
- No
robots.txt
/sitemap.xml
→ poor crawl coverage - JavaScript-only content for critical copy → render on server where possible
Pre-Publish SEO Checklist
- Unique title and meta description
- Canonical set (and hreflang if multilingual)
- Open Graph/Twitter images present
- JSON-LD Article + (optional) BreadcrumbList
- Images optimized (
<Image>
, alt text, correct sizes) - LCP < 2.5s on mobile; CLS < 0.1; INP < 200ms
robots.txt
andsitemap.xml
generated- Internal links to and from related posts
In Summary: Next.js ships the right primitives to make SEO straightforward—pre-rendered HTML, a powerful Metadata API, easy structured data, and performance tools to hit Core Web Vitals. Put these patterns in place once, and every new page you publish will be set up to rank.
August 2025