| name | seo-optimizer-nextjs |
| description | Implement complete SEO for any Next.js App Router project. Covers metadata, structured data, sitemap, robots, OG images, i18n hreflang, LLM content, and Core Web Vitals hints. Adapts to any site type via 3 questions. |
Add Next.js SEO
Implement full SEO for a Next.js App Router project. Use {DOMAIN}, {SITE_NAME}, {DESCRIPTION} as placeholders — replace with real values from the project.
Step 0: Ask 3 Questions (REQUIRED — do this before touching any file)
Ask the user these questions and wait for all answers before proceeding:
Before I start, I need 3 quick answers to tailor the SEO implementation:
1. What type is this website?
A) Blog / News / Magazine
B) E-commerce / Marketplace
C) SaaS / Web Application
D) Local Business / Service Provider
E) Portfolio / Agency / Personal Brand
F) Documentation / Knowledge Base
2. Is this a multilingual site?
A) Yes — which locales? (e.g. en, ar, fr, de)
B) No — single language (which one?)
3. Where does the content come from?
A) All static (hardcoded pages, no slugs)
B) Dynamic from CMS/API (blog posts, products, etc. with [slug] routes)
C) Mixed (some static + some dynamic)
Based on answers, apply the relevant sections below.
Step 1: Explore the Project
Before implementing, read:
src/app/layout.tsx — root layout
src/app/sitemap.ts or public/sitemap.xml — existing sitemap
public/robots.txt or src/app/robots.ts — existing robots
next.config.* — i18n config, domain config
package.json — Next.js version
Use TodoWrite to track all checklist items, then implement one by one.
Checklist (track with TodoWrite)
Universal (all sites):
By site type:
If multilingual:
If dynamic content:
1. Root Layout Metadata
src/app/layout.tsx:
import type { Metadata, Viewport } from "next";
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? "https://{DOMAIN}"),
title: {
default: "{SITE_NAME}",
template: "%s | {SITE_NAME}",
},
description: "{DESCRIPTION}",
keywords: ["keyword1", "keyword2", "keyword3"],
authors: [{ name: "{SITE_NAME}", url: "https://{DOMAIN}" }],
creator: "{SITE_NAME}",
publisher: "{SITE_NAME}",
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
noimageindex: false,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
type: "website",
locale: "en_US",
url: "https://{DOMAIN}",
siteName: "{SITE_NAME}",
title: "{SITE_NAME}",
description: "{DESCRIPTION}",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "{SITE_NAME}",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
site: "@{TWITTER_HANDLE}",
creator: "@{TWITTER_HANDLE}",
title: "{SITE_NAME}",
description: "{DESCRIPTION}",
images: ["/og-image.png"],
},
alternates: {
canonical: "https://{DOMAIN}",
},
verification: {
google: "{GOOGLE_SEARCH_CONSOLE_TOKEN}",
},
icons: {
icon: [
{ url: "/favicon.ico" },
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
shortcut: "/favicon.ico",
},
manifest: "/manifest.json",
category: "{SITE_CATEGORY}",
};
2. Page-Specific Metadata
Static pages — add layout.tsx per route:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description: "Unique description 150–160 chars",
alternates: { canonical: "https://{DOMAIN}/about" },
openGraph: {
title: "About | {SITE_NAME}",
description: "Unique description",
url: "https://{DOMAIN}/about",
},
};
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
3. Dynamic Metadata (if answer 3 = B or C)
For [slug] routes:
import type { Metadata, ResolvingMetadata } from "next";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return { title: "Not Found" };
const parentOg = (await parent).openGraph?.images ?? [];
return {
title: post.title,
description: post.excerpt,
keywords: post.tags,
authors: [{ name: post.author.name }],
openGraph: {
type: "article",
title: post.title,
description: post.excerpt,
url: `https://{DOMAIN}/blog/${slug}`,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
tags: post.tags,
images: post.coverImage
? [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }]
: parentOg,
},
twitter: { card: "summary_large_image", title: post.title },
alternates: { canonical: `https://{DOMAIN}/blog/${slug}` },
};
}
4. robots.ts
src/app/robots.ts (replaces public/robots.txt):
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "https://{DOMAIN}";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/_next/", "/private/"],
},
{ userAgent: "GPTBot", allow: ["/llms.txt", "/llms-full.txt"] },
{ userAgent: "Claude-Web", allow: ["/llms.txt", "/llms-full.txt"] },
{ userAgent: "Anthropic-AI", allow: ["/llms.txt", "/llms-full.txt"] },
{ userAgent: "PerplexityBot", allow: ["/llms.txt", "/llms-full.txt"] },
{ userAgent: "Googlebot-Image", allow: "/" },
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
};
}
5. Sitemap
Single Language
src/app/sitemap.ts:
import type { MetadataRoute } from "next";
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "https://{DOMAIN}";
async function getDynamicRoutes(): Promise<MetadataRoute.Sitemap> {
return [];
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const dynamic = await getDynamicRoutes();
const static_: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1.0,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.5,
},
];
return [...static_, ...dynamic];
}
Multilingual Sitemap (if answer 2 = A)
import type { MetadataRoute } from "next";
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "https://{DOMAIN}";
const locales = ["{LOCALE_1}", "{LOCALE_2}"] as const;
const staticRoutes = [
{ path: "", changeFrequency: "daily" as const, priority: 1.0 },
{ path: "/about", changeFrequency: "monthly" as const, priority: 0.8 },
{ path: "/blog", changeFrequency: "daily" as const, priority: 0.9 },
{ path: "/contact", changeFrequency: "yearly" as const, priority: 0.5 },
];
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const entries: MetadataRoute.Sitemap = [];
for (const { path, changeFrequency, priority } of staticRoutes) {
for (const locale of locales) {
entries.push({
url: `${baseUrl}/${locale}${path}`,
lastModified: new Date(),
changeFrequency,
priority,
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${baseUrl}/${l}${path}`]),
),
},
});
}
}
return entries;
}
6. i18n Metadata (if answer 2 = A)
In each page's generateMetadata or static metadata, add alternates:
export const metadata: Metadata = {
alternates: {
canonical: "https://{DOMAIN}/{CURRENT_LOCALE}/page",
languages: {
en: "https://{DOMAIN}/en/page",
ar: "https://{DOMAIN}/ar/page",
"x-default": "https://{DOMAIN}/en/page",
},
},
openGraph: {
locale: "en_US",
alternateLocale: ["ar_SA"],
},
};
7. Structured Data (JSON-LD)
Inject into layout with <script type="application/ld+json">. All sites get WebSite + Organization. Add type-specific schemas below.
WebSite + Organization (root layout — ALL sites)
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "{SITE_NAME}",
url: "https://{DOMAIN}",
description: "{DESCRIPTION}",
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: "https://{DOMAIN}/search?q={search_term_string}",
},
"query-input": "required name=search_term_string",
},
};
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
name: "{SITE_NAME}",
url: "https://{DOMAIN}",
logo: {
"@type": "ImageObject",
url: "https://{DOMAIN}/logo.png",
width: 512,
height: 512,
},
sameAs: [
"https://twitter.com/{HANDLE}",
"https://www.linkedin.com/company/{HANDLE}",
"https://www.facebook.com/{HANDLE}",
],
contactPoint: {
"@type": "ContactPoint",
contactType: "customer service",
email: "support@{DOMAIN}",
availableLanguage: ["English"],
},
};
BreadcrumbList (on all inner pages)
function BreadcrumbSchema({
items,
}: {
items: { name: string; url: string }[];
}) {
const schema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: item.url,
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Blog/News: Article + Person (answer 1 = A)
const articleSchema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
image: {
"@type": "ImageObject",
url: post.coverImage,
width: 1200,
height: 630,
},
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author.name,
url: `https://{DOMAIN}/authors/${post.author.slug}`,
},
publisher: {
"@type": "Organization",
name: "{SITE_NAME}",
logo: { "@type": "ImageObject", url: "https://{DOMAIN}/logo.png" },
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://{DOMAIN}/blog/${post.slug}`,
},
keywords: post.tags?.join(", "),
articleSection: post.category,
wordCount: post.wordCount,
inLanguage: "en-US",
};
E-commerce: Product + Offer (answer 1 = B)
const productSchema = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.images.map((img) => img.url),
sku: product.sku,
brand: { "@type": "Brand", name: product.brand },
offers: {
"@type": "Offer",
url: `https://{DOMAIN}/products/${product.slug}`,
priceCurrency: "USD",
price: product.price,
priceValidUntil: new Date(Date.now() + 30 * 86400000)
.toISOString()
.split("T")[0],
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
seller: { "@type": "Organization", name: "{SITE_NAME}" },
shippingDetails: {
"@type": "OfferShippingDetails",
shippingRate: { "@type": "MonetaryAmount", value: "0", currency: "USD" },
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime: {
"@type": "QuantitativeValue",
minValue: 0,
maxValue: 1,
unitCode: "DAY",
},
transitTime: {
"@type": "QuantitativeValue",
minValue: 3,
maxValue: 7,
unitCode: "DAY",
},
},
},
},
aggregateRating:
product.reviewCount > 0
? {
"@type": "AggregateRating",
ratingValue: product.averageRating,
reviewCount: product.reviewCount,
bestRating: 5,
worstRating: 1,
}
: undefined,
};
SaaS / Web App: SoftwareApplication + FAQPage (answer 1 = C)
const softwareSchema = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "{SITE_NAME}",
applicationCategory: "WebApplication",
operatingSystem: "Web",
url: "https://{DOMAIN}",
description: "{DESCRIPTION}",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
aggregateRating: {
"@type": "AggregateRating",
ratingValue: "4.8",
ratingCount: "1200",
},
featureList: ["Feature 1", "Feature 2", "Feature 3"],
};
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: { "@type": "Answer", text: faq.answer },
})),
};
Local Business (answer 1 = D)
const localBusinessSchema = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: "{SITE_NAME}",
description: "{DESCRIPTION}",
url: "https://{DOMAIN}",
telephone: "+1-XXX-XXX-XXXX",
email: "contact@{DOMAIN}",
address: {
"@type": "PostalAddress",
streetAddress: "123 Main St",
addressLocality: "City",
addressRegion: "State",
postalCode: "00000",
addressCountry: "US",
},
geo: { "@type": "GeoCoordinates", latitude: 0.0, longitude: 0.0 },
openingHoursSpecification: [
{
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "09:00",
closes: "18:00",
},
],
image: "https://{DOMAIN}/og-image.png",
priceRange: "$$",
sameAs: ["https://www.google.com/maps/place/..."],
};
Portfolio / Personal Brand (answer 1 = E)
const personSchema = {
"@context": "https://schema.org",
"@type": "Person",
name: "{FULL_NAME}",
url: "https://{DOMAIN}",
image: "https://{DOMAIN}/photo.jpg",
jobTitle: "{JOB_TITLE}",
description: "{DESCRIPTION}",
email: "contact@{DOMAIN}",
sameAs: [
"https://linkedin.com/in/{HANDLE}",
"https://github.com/{HANDLE}",
"https://twitter.com/{HANDLE}",
],
knowsAbout: ["Skill 1", "Skill 2"],
};
Documentation (answer 1 = F)
const techArticleSchema = {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: doc.title,
description: doc.description,
datePublished: doc.publishedAt,
dateModified: doc.updatedAt,
author: { "@type": "Organization", name: "{SITE_NAME}" },
proficiencyLevel: "Beginner",
dependencies: "Next.js 14+",
};
8. Dynamic OG Image
src/app/og/route.tsx:
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") ?? "{SITE_NAME}";
const description = searchParams.get("description") ?? "";
return new ImageResponse(
<div
style={{
width: "1200px",
height: "630px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #1e1e2e 0%, #313244 100%)",
padding: "80px",
fontFamily: "system-ui, sans-serif",
}}
>
<div
style={{
color: "#cba6f7",
fontSize: "20px",
marginBottom: "16px",
letterSpacing: "0.2em",
textTransform: "uppercase",
}}
>
{"{SITE_NAME}"}
</div>
<h1
style={{
color: "#cdd6f4",
fontSize: "56px",
fontWeight: "bold",
textAlign: "center",
margin: 0,
lineHeight: 1.2,
}}
>
{title}
</h1>
{description && (
<p
style={{
color: "#a6adc8",
fontSize: "24px",
textAlign: "center",
marginTop: "24px",
maxWidth: "900px",
}}
>
{description}
</p>
)}
</div>,
{ width: 1200, height: 630 },
);
}
Then reference in metadata:
openGraph: {
images: [
{
url: `/og?title=${encodeURIComponent(post.title)}&description=${encodeURIComponent(post.excerpt)}`,
width: 1200, height: 630,
},
],
},
9. LLMs.txt (AI Discoverability)
src/app/llms.txt/route.ts — Brief overview
import { NextResponse } from "next/server";
export async function GET() {
const content = `# {SITE_NAME}
> {DESCRIPTION}
## What We Offer
{SITE_NAME} provides [brief product/service description in 2–3 sentences].
## Key Sections
- [Home](https://{DOMAIN})
- [About](https://{DOMAIN}/about)
- [Blog](https://{DOMAIN}/blog)
- [Contact](https://{DOMAIN}/contact)
## For AI Systems
- Full documentation: https://{DOMAIN}/llms-full.txt
- Sitemap: https://{DOMAIN}/sitemap.xml
- Contact: contact@{DOMAIN}
`;
return new NextResponse(content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=86400",
},
});
}
src/app/llms-full.txt/route.ts — Comprehensive
import { NextResponse } from "next/server";
export async function GET() {
const content = `# {SITE_NAME} — Complete Reference
> {DESCRIPTION}
## Table of Contents
1. Overview
2. Key Features
3. How to Use
4. Content Index
5. Contact & Links
## 1. Overview
[2–4 paragraph overview of what the site does, who it's for, and key value proposition]
## 2. Key Features
- **Feature A**: [Description]
- **Feature B**: [Description]
- **Feature C**: [Description]
## 3. How to Use
[Step-by-step guide or key user flows]
## 4. Content Index
[List key pages with descriptions — especially useful for docs/blog sites]
## 5. Contact & Links
- Website: https://{DOMAIN}
- Support: support@{DOMAIN}
- X: https://X.com/{HANDLE}
- Sitemap: https://{DOMAIN}/sitemap.xml
`;
return new NextResponse(content, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}
10. Custom 404
src/app/not-found.tsx:
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "404 – Page Not Found",
description: "The page you're looking for doesn't exist.",
robots: { index: false, follow: false },
};
export default function NotFound() {
return (
<main
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
gap: "16px",
}}
>
<h1>404 — Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<Link href="/">Go back home</Link>
</main>
);
}
File Reference
| File | Purpose |
|---|
src/app/layout.tsx | Root metadata + viewport + Organization/WebSite JSON-LD |
src/app/{route}/layout.tsx | Page-specific metadata |
src/app/{route}/[slug]/page.tsx | generateMetadata for dynamic routes |
src/app/sitemap.ts | Dynamic sitemap (replaces static XML) |
src/app/robots.ts | Programmatic robots (replaces public/robots.txt) |
src/app/og/route.tsx | Edge-rendered dynamic OG images |
src/app/not-found.tsx | Custom 404 page |
src/app/llms.txt/route.ts | AI-friendly site overview |
src/app/llms-full.txt/route.ts | Full AI-friendly documentation |
public/og-image.png | Static fallback OG image (1200×630px) |
public/manifest.json | PWA manifest |
Common Mistakes
| Issue | Fix |
|---|
Missing metadataBase | Always set — required for absolute OG image URLs |
viewport in metadata | Move to separate export const viewport: Viewport |
| Duplicate titles/descriptions | Every page must be unique |
No canonical on dynamic pages | Set alternates.canonical in generateMetadata |
Missing x-default hreflang | Add when multilingual |
<img> instead of next/image | Always use next/image for LCP images |
| Structured data not validated | Test with Google Rich Results Test |
| robots.txt blocks CSS/JS | Never disallow /\_next/static/ in production |
| No fallback OG image | Always have public/og-image.png as fallback |
After Implementation
Run /audit-seo to verify completeness and get a scored report.
Validate structured data: https://search.google.com/test/rich-results
Test OG tags: https://www.opengraph.xyz
Test Core Web Vitals: https://pagespeed.web.dev