nextjs-seo
Skill0 starsChanged 2 months agoMITView on GitHub
Implement SEO correctly in a Next.js App Router or Pages Router codebase — metadata, canonicals, sitemaps, robots, Open Graph images, structured data, and rendering strategy. Use whenever the user is working in a Next.js project and mentions SEO, metadata, generateMetadata, sitemap.ts, robots.ts, canonical URLs, Open Graph, og:image, JSON-LD, indexing, or asks why Next.js pages aren't ranking or showing correct previews. Also use when reviewing a Next.js marketing site, blog or docs site before launch.
3 other projects use a version of this.
Adopted by
Most recently taken up by
- laguagu/claude-code-nextjs-skillstheir own version, first seen Oct 26, 2026
- tysongreenan/claude-code-webapp-boilerplatetheir own version, first seen Apr 8, 2026
- mm-mazhar/agent-skills-configtheir own version, first seen Feb 10, 2026
Other versions3 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 3
- laguagu/claude-code-nextjs-skills★ 72same name
- tysongreenan/claude-code-webapp-boilerplate★ 2same name
- mm-mazhar/agent-skills-config★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository29 other files it gives its agents.
- ai-bot-log-analysisSkill
- ai-crawler-policySkill
- ai-visibility-auditSkill
- best-of-listicle-pagesSkill
- canonical-duplicate-contentSkill
- comparison-alternatives-pagesSkill
- content-refreshSkill
- core-web-vitalsSkill
- ecommerce-seoSkill
- eeat-author-pagesSkill
- entity-brand-consistencySkill
- featured-snippet-passagesSkill
- glossary-definition-hubSkill
- hreflang-international-seoSkill
- image-seoSkill
- internal-linking-architectureSkill
- keyword-cannibalizationSkill
- keyword-research-briefsSkill
- link-building-outreachSkill
- local-seoSkill
- programmatic-seoSkill
- redirect-migration-planSkill
- schema-markupSkill
- search-console-analysisSkill
- shopify-seoSkill
- technical-seo-auditSkill
- topical-authority-mapSkill
- video-seoSkill
- wordpress-seoSkill
The file
We split this skill into the rules it gives an agent
3 rules. 0 are common, 2 are distinctive, and 1 is very specific to this project.
0
Common
Most of the 3 edited versions we read keep these.
2
Distinctive
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 157
Every element in an ImageResponse needs an explicit display: flex when it has more than one child.
- Distinctive: line 199
Verify by looking at what the server actually sends:
- Distinctive: line 206
If the article text isn't in that output, no metadata work will save the page.
“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Next.js SEO
Next.js gives you everything needed for good SEO and defaults to almost none of it. The gaps are consistent across projects, so this is mostly a checklist of things to add rather than problems to debug.
Assume App Router unless the code says otherwise. Pages Router equivalents are noted at the end.
The root layout
Set the metadata base and the defaults once. metadataBase is the one people miss, and without it every Open Graph and canonical URL is emitted relative, which breaks previews everywhere.
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Example — short promise',
template: '%s | Example',
},
description: 'One sentence a human would read. 140-160 characters.',
alternates: { canonical: '/' },
openGraph: {
type: 'website',
siteName: 'Example',
locale: 'en_US',
url: '/',
},
twitter: { card: 'summary_large_image' },
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, 'max-image-preview': 'large', 'max-snippet': -1 },
},
}max-image-preview: large and max-snippet: -1 are worth setting explicitly. Without them some surfaces show a truncated snippet and a thumbnail instead of a full-size image.
Per-page metadata
Static pages export a metadata object. Dynamic pages use generateMetadata, which runs on the server and is deduped against the page's own data fetch, so fetching the same record twice costs nothing.
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
url: `/blog/${slug}`,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [{ url: post.ogImage, width: 1200, height: 630, alt: post.title }],
},
}
}Rules that prevent the common bugs:
- Always set
alternates.canonicalon every page, as a path. Next resolves it againstmetadataBase. Without it, query strings and trailing-slash variants become separate indexable URLs. paramsis a Promise in recent App Router versions. Await it.- Return
{}and callnotFound()for missing records. Don't emit metadata for a page that will 404. generateMetadataandgenerateStaticParamsare the pair that make dynamic routes statically rendered. Without the second, every page is rendered on demand.
robots.ts and sitemap.ts
Both are file conventions. Use them rather than static files in public/, so they stay in sync with the routes.
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/', '/draft/'] },
// AI crawler policy is a separate decision — see the ai-crawler-policy skill
],
sitemap: 'https://example.com/sitemap.xml',
host: 'https://example.com',
}
}// app/sitemap.ts
import type { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const staticRoutes = ['', '/pricing', '/about', '/blog'].map((route) => ({
url: `https://example.com${route}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: route === '' ? 1 : 0.8,
}))
const postRoutes = posts.map((p) => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: new Date(p.updatedAt),
changeFrequency: 'monthly' as const,
priority: 0.6,
}))
return [...staticRoutes, ...postRoutes]
}Above roughly 50,000 URLs, use generateSitemaps to shard. And put only canonical, indexable, 200-status URLs in there. A sitemap listing redirects and noindexed pages is a quality signal pointing the wrong way.
lastModified should be the real modification date. Setting it to new Date() for every post on every build tells Google everything changed every deploy, which it will stop believing.
Open Graph images
Generate them at the edge rather than designing 200 of them.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export const alt = 'Article preview'
export default async function Image({ params }) {
const post = await getPost((await params).slug)
return new ImageResponse(
(
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'flex-end',
width: '100%', height: '100%', padding: 80, background: '#0b0b0c' }}>
<div style={{ fontSize: 64, color: 'white', lineHeight: 1.1 }}>{post.title}</div>
<div style={{ fontSize: 28, color: '#9ca3af', marginTop: 24 }}>example.com</div>
</div>
),
size
)
}Every element in an ImageResponse needs an explicit display: flex when it has more than one child. This is the error everyone hits once.
Structured data
JSON-LD goes in the server component directly. Don't use a client component and don't inject it after hydration.
export default async function Page({ params }) {
const post = await getPost((await params).slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.author.name, url: post.author.url },
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* ... */}</article>
</>
)
}Shape and required fields are in schema-markup.
Rendering strategy
This is where Next.js sites actually lose rankings, and it's invisible in development.
'use client'at the top of a page makes the whole subtree client-rendered. Googlebot renders it eventually; most AI retrieval agents don't render at all. Keep pages as server components and push'use client'down to the leaf that needs interactivity.dynamic = 'force-dynamic'or an uncachedfetchon a marketing page means server rendering on every request, which is slower and pointless for content that changes weekly. Use static generation with revalidation.generateStaticParamson dynamic routes, so the pages are pre-rendered instead of built on first request.next/linkrenders real anchors. Routing viarouter.pushin an onClick handler does not, and those links are invisible to crawlers.next/imagewith explicitwidth,heightand a realalt. Setpriorityon the LCP image and nothing else.
Verify by looking at what the server actually sends:
curl -s https://example.com/blog/post | grep -oE "<title>[^<]*|canonical[^>]*|<h1[^>]*>[^<]*"
curl -s https://example.com/blog/post | sed -e 's/<[^>]*>//g' | tr -s '[:space:]' ' ' | head -c 1000If the article text isn't in that output, no metadata work will save the page.
Redirects and trailing slashes
Pick one trailing-slash convention in next.config.js and never change it. Both forms resolving with a 200 is a duplicate-content bug that Next.js will happily create.
Permanent redirects belong in next.config.js (permanent: true emits a 308) or in middleware for pattern-based rules. Keep chains to one hop. See redirect-migration-plan.
Pages Router equivalents
| App Router | Pages Router |
metadata / generateMetadata | next/head in the page, or a shared <Seo> component |
app/sitemap.ts | pages/sitemap.xml.tsx with getServerSideProps |
app/robots.ts | public/robots.txt |
opengraph-image.tsx | pages/api/og.tsx with ImageResponse |
| Server components by default | getStaticProps / getServerSideProps |
The rendering advice is the same: get the content into the HTML the server sends.
Pre-launch checklist
metadataBaseset- Unique title and description on every route
alternates.canonicalon every routerobots.tsnot blocking anything real, and not left in staging statesitemap.tscovering all indexable routes and nothing else- OG image renders and previews correctly
- Content present in
curloutput without JavaScript - One
h1per page - Trailing slash behaviour decided and consistent
noindexremoved from production, and present on staging
That last pair is the single most common Next.js launch failure: staging indexed, production noindexed, both discovered a month later.
Related skills
technical-seo-audit, schema-markup, core-web-vitals, canonical-duplicate-content, redirect-migration-plan, image-seo, ai-crawler-policy
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

