Why Next.js Is the Best Framework for SEO
After building dozens of sites across WordPress, React SPAs, Gatsby, Astro, and Next.js, Next.js with the App Router is my consistent recommendation for any project where SEO performance matters. The reasons are concrete: server-side rendering is the default (no client-side-only content that crawlers miss), the Metadata API is purpose-built for search and social metadata, image optimization is baked in, and ISR (Incremental Static Regeneration) gives you static performance with dynamic content.
The framework handles many SEO fundamentals automatically, but the automatic handling only works if you configure it correctly. This checklist covers everything you need to set up — drawn directly from my full-stack development work on production Next.js sites.
Metadata API: Static and Dynamic
The App Router Metadata API replaces the manual <Head> manipulation from Pages Router and Next.js 12. Export a metadata object for static metadata or a generateMetadata function for dynamic metadata that depends on route parameters or fetched data.
// app/layout.tsx — Root layout with site-wide defaults
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://yourdomain.com"),
title: {
template: "%s | Your Site Name",
default: "Your Site Name — Tagline",
},
description: "Your site description — 150-160 characters for meta description.",
openGraph: {
type: "website",
locale: "en_US",
url: "https://yourdomain.com",
siteName: "Your Site Name",
},
twitter: {
card: "summary_large_image",
creator: "@yourhandle",
},
robots: {
index: true,
follow: true,
},
verification: {
google: "your-google-verification-code",
},
};
// app/blog/[slug]/page.tsx — Dynamic metadata for blog posts
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return { title: "Post Not Found" };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
url: `https://yourdomain.com/blog/${slug}`,
images: [
{
url: `/api/og?title=${encodeURIComponent(post.title)}`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
alternates: {
canonical: `https://yourdomain.com/blog/${slug}`,
},
};
}
// Also export generateStaticParams for build-time generation
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Dynamic Sitemap and robots.txt
Next.js generates sitemaps and robots.txt through special route files in the app directory. These are server-rendered and can include dynamic content from your database or CMS.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/blog";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const BASE = "https://yourdomain.com";
const staticRoutes: MetadataRoute.Sitemap = [
{ url: BASE, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
{ url: `${BASE}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
{ url: `${BASE}/services`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
{ url: `${BASE}/blog`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 },
{ url: `${BASE}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.6 },
];
const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${BASE}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "monthly",
priority: 0.7,
}));
return [...staticRoutes, ...postRoutes];
}
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/_next/"],
},
],
sitemap: "https://yourdomain.com/sitemap.xml",
};
}
Structured Data (JSON-LD) in Next.js
Inject JSON-LD structured data using a <script> tag in your layout or page components. Server Components render this in the HTML before JavaScript runs, so it is immediately visible to crawlers.
// components/StructuredData.tsx
interface Props {
data: Record<string, unknown>;
}
export function StructuredData({ data }: Props) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
// Usage in a blog post page:
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
author: {
"@type": "Person",
name: post.author.name,
url: "https://yourdomain.com/about",
},
datePublished: post.publishedAt,
dateModified: post.updatedAt,
publisher: {
"@type": "Organization",
name: "Your Site Name",
logo: {
"@type": "ImageObject",
url: "https://yourdomain.com/logo.png",
},
},
};
return (
<>
<StructuredData data={articleSchema} />
{/* page content */}
</>
);
}
Image Optimization with next/image
The next/image component handles WebP/AVIF conversion, responsive sizing, lazy loading, and layout shift prevention automatically. Use it for every image on the site.
import Image from "next/image";
// Hero image — eager load, no lazy loading, high priority
<Image
src="/hero.jpg"
alt="Descriptive alt text with relevant keywords"
width={1200}
height={630}
priority // preloads the image — use only for LCP images
quality={85}
sizes="(max-width: 768px) 100vw, 50vw"
/>
// Below-the-fold image — lazy loaded by default
<Image
src="/project-screenshot.jpg"
alt="Screenshot of the project dashboard"
width={800}
height={450}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
Warning: Only use priority on the LCP image (usually the hero image). Applying it to multiple images causes them to compete for bandwidth and can actually worsen LCP performance.
Core Web Vitals in Next.js
Next.js 15 ships with built-in performance optimizations, but you need to configure them correctly:
Partial Prerendering (PPR): Available in Next.js 15, PPR statically renders the page shell and streams dynamic content. This dramatically improves TTFB for pages with dynamic sections — the static shell loads instantly from CDN while dynamic content streams in.
Font optimization: Use next/font instead of loading Google Fonts via a link tag. next/font downloads fonts at build time, self-hosts them, and adds font-display: swap automatically.
// app/layout.tsx
import { Inter, Geist } from "next/font/google";
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist",
display: "swap",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={geist.variable}>
<body>{children}</body>
</html>
);
}
Script optimization: Use next/script for third-party scripts with appropriate loading strategies. Use strategy="afterInteractive" for analytics (Google Analytics, PostHog) and strategy="lazyOnload" for chat widgets that do not need to be interactive immediately.
Open Graph Images with next/og
Dynamic OG images generated with the ImageResponse API give every page a unique, properly-sized social share image without manually creating PNGs for each page.
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") || "Default Title";
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "60px",
width: "100%",
height: "100%",
background: "linear-gradient(135deg, #0d1117 0%, #1a2333 100%)",
color: "white",
fontFamily: "sans-serif",
}}
>
<p style={{ fontSize: 24, color: "#22d3ee", marginBottom: 16 }}>
yourdomain.com
</p>
<h1 style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.2, margin: 0 }}>
{title}
</h1>
</div>
),
{ width: 1200, height: 630 }
);
}
This setup covers the complete technical SEO foundation for a Next.js site. The combination of proper metadata, dynamic sitemaps, structured data, image optimization, and Core Web Vitals configuration gives you a strong technical baseline that directly supports rankings. For full-stack Next.js development with SEO built in from day one, see my full-stack services and project examples.