Learn SEO for Next.js
64 patterns across 8 categories. Each one shows the convention, a side-by-side example, and why it matters.
Start here
New to SEO in Next.js? Follow these five categories in order.
Meta Tags
The title tag, meta description, and viewport meta. These are the foundation of every page's search appearance. You will hit this when your page shows 'Untitled' in browser tabs or gets a generic snippet in search results.
// app/about/page.tsx
export default function AboutPage() {
return (
<>
<head>
<title>About Us | Acme Corp</title>
</head>
<main>
<h1>About Us</h1>
<p>We build great products.</p>
</main>
</>
);
}// app/about/page.tsx
export default function AboutPage() {
return (
<>
<head>
<title>About Us | Acme Corp</title>
</head>
<main>
<h1>About Us</h1>
<p>We build great products.</p>
</main>
</>
);
}// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
};
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>We build great products.</p>
</main>
);
}// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
};
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>We build great products.</p>
</main>
);
}Open Graph
og:title, og:image, og:description, and the rest of the Open Graph protocol. These control how your links appear when shared on LinkedIn, Facebook, Slack, Teams, and Discord. You will hit this when a shared link shows a blank card or the wrong image.
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us | Acme Corp",
// No openGraph defined, hoping
// the title tag is enough
};// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us | Acme Corp",
// No openGraph defined, hoping
// the title tag is enough
};// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us | Acme Corp",
openGraph: {
title: "About Us",
description: "Learn about Acme Corp and our team.",
},
};// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us | Acme Corp",
openGraph: {
title: "About Us",
description: "Learn about Acme Corp and our team.",
},
};Twitter Cards
twitter:card, twitter:image, and the difference between summary and summary_large_image. These control how your links look on Twitter/X. You will hit this when your tweet shows a tiny thumbnail instead of a large preview image.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
twitter: {
card: "summary",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
twitter: {
card: "summary",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
return {
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}Canonical URLs
The canonical link element, trailing slash handling, www vs non-www, and how to tell search engines which URL is the original. You will hit this when Google indexes three versions of the same page and splits your ranking.
// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about our company.",
// No canonical URL set
};// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about our company.",
// No canonical URL set
};// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about our company.",
alternates: {
canonical: "https://acme.com/about",
},
};// app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about our company.",
alternates: {
canonical: "https://acme.com/about",
},
};Sitemaps & Robots
The sitemap.ts and robots.ts files in Next.js, how to control crawling, and when to use noindex vs disallow. You will hit this when search engines cannot find your new pages or index pages you wanted to keep private.
// public/sitemap.xml (static file)
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://acme.com</loc>
</url>
<url>
<loc>https://acme.com/about</loc>
</url>
<!-- Must manually update -->
</urlset>// public/sitemap.xml (static file)
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://acme.com</loc>
</url>
<url>
<loc>https://acme.com/about</loc>
</url>
<!-- Must manually update -->
</urlset>// app/sitemap.ts
import type { MetadataRoute } from "next";
export default function sitemap():
MetadataRoute.Sitemap {
return [
{
url: "https://acme.com",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: "https://acme.com/about",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
];
}// app/sitemap.ts
import type { MetadataRoute } from "next";
export default function sitemap():
MetadataRoute.Sitemap {
return [
{
url: "https://acme.com",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: "https://acme.com/about",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
];
}Structured Data
JSON-LD, schema.org markup, and how to add rich results like FAQ accordions, breadcrumbs, and article metadata to your Next.js pages. You will hit this when your competitors show star ratings in search results and you do not.
// app/page.tsx
export const metadata: Metadata = {
other: {
"application/ld+json": JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "Acme Corp",
}),
},
};
export default function Home() {
return <main>Welcome to Acme</main>;
}// app/page.tsx
export const metadata: Metadata = {
other: {
"application/ld+json": JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "Acme Corp",
}),
},
};
export default function Home() {
return <main>Welcome to Acme</main>;
}// app/page.tsx
export default function Home() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "Acme Corp",
url: "https://acme.com",
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd),
}}
/>
Welcome to Acme
</main>
);
}// app/page.tsx
export default function Home() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "Acme Corp",
url: "https://acme.com",
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd),
}}
/>
Welcome to Acme
</main>
);
}Image Optimization
The opengraph-image.tsx convention, proper image dimensions for social sharing, next/image for Core Web Vitals, and how image size affects page speed. You will hit this when your OG image gets cropped on LinkedIn or your LCP score tanks.
// app/opengraph-image.png
// A static PNG file in the app directory
// Same image for every page on the site
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
openGraph: {
images: ["/opengraph-image.png"],
},
};// app/opengraph-image.png
// A static PNG file in the app directory
// Same image for every page on the site
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
openGraph: {
images: ["/opengraph-image.png"],
},
};// app/blog/[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 = await getPost(params.slug);
return new ImageResponse(
(
<div style={{
display: "flex",
fontSize: 48,
background: "linear-gradient(135deg, \
#667eea 0%, #764ba2 100%)",
color: "white",
width: "100%",
height: "100%",
padding: 60,
alignItems: "center",
justifyContent: "center",
}}>
{post.title}
</div>
),
size
);
}// app/blog/[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 = await getPost(params.slug);
return new ImageResponse(
(
<div style={{
display: "flex",
fontSize: 48,
background: "linear-gradient(135deg, \
#667eea 0%, #764ba2 100%)",
color: "white",
width: "100%",
height: "100%",
padding: 60,
alignItems: "center",
justifyContent: "center",
}}>
{post.title}
</div>
),
size
);
}Internationalization
hreflang tags, alternate links, Next.js i18n routing, and locale-specific metadata. You will hit this when Google shows the wrong language version of your page to users in a different country.
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Acme Corp",
// No hreflang tags
// Google may show the wrong language
// version to users in other countries
};// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Acme Corp",
// No hreflang tags
// Google may show the wrong language
// version to users in other countries
};// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Acme Corp",
alternates: {
languages: {
"en-US": "https://acme.com/en",
"de-DE": "https://acme.com/de",
"fr-FR": "https://acme.com/fr",
},
},
};// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Acme Corp",
alternates: {
languages: {
"en-US": "https://acme.com/en",
"de-DE": "https://acme.com/de",
"fr-FR": "https://acme.com/fr",
},
},
};