npx skills add ...
npx skills add laguagu/claude-code-nextjs-skills --skill nextjs-seo
Next.js App Router SEO optimization and auditing. Use when implementing or fixing SEO in a Next.js app — metadata and generateMetadata, viewport/themeColor, Open Graph and og/twitter images (file conventions + ImageResponse), web app manifest, favicons/icons, sitemap.xml, robots.txt, canonical URLs, hreflang/i18n alternates, JSON-LD structured data and rich results, Core Web Vitals (LCP/INP/CLS), AI search/GEO and AI crawler rules (GPTBot, OAI-SearchBot), or diagnosing Google indexing problems (Search Console, "Discovered/Crawled - currently not indexed"). Also use to run an SEO audit checklist. Not for general Next.js feature work unrelated to SEO.
npx skills add laguagu/claude-code-nextjs-skills --skill nextjs-seo
Comprehensive SEO guide for Next.js App Router applications.
Run this checklist for any Next.js project:
curl https://your-site.com/robots.txtcurl https://your-site.com/sitemap.xml<title> and <meta name="description">application/ld+jsonlastModified must reflect the content's actual last change (CMS updatedAt, file mtime, git commit date) — Google uses lastmod only when it's consistently accurate, and new Date() on every build marks everything "just changed", which teaches Google to ignore it. Skip changeFrequency and priority: Google ignores both.
hostwas omitted intentionally — it's a non-standard directive Google ignores. Use canonical URLs / 301s to declare the preferred host instead. See references/sitemap-robots.md.
Same MetadataRoute family as sitemap/robots, placed at the root of app/. Not an SEO requirement — a PWA-completeness nicety with no ranking effect; skip it unless the site is (or may become) a PWA. Full example in references/metadata-api.md.
Three ways to set social images — prefer the file conventions over hand-syncing URLs in the metadata object:
openGraph.images / twitter.images examples above) — fine for externally hosted images.opengraph-image.(png|jpg|gif) and/or twitter-image.* into a route segment (app/opengraph-image.png for the root, app/blog/opengraph-image.png for /blog). Next.js auto-emits og:image/twitter:image + :type/:width/:height. A deeper, more specific image overrides one above it. Add alt text with a sibling opengraph-image.alt.txt. Build fails if the file exceeds 8 MB (OG) / 5 MB (Twitter).ImageResponse (per-page/per-post images): an opengraph-image.tsx in the route segment exporting alt, size, contentType and a default Image({ params }) (params is a Promise in v16) that returns new ImageResponse(<jsx/>, { ...size }). Renders via Satori — flexbox only, no display: grid; statically optimized at build time unless it reads request-time data. Full example, fonts, generateImageMetadata and the favicon/icon.tsx/apple-icon conventions: references/metadata-api.md.With cacheComponents: true in next.config.ts (the v16 top-level flag that unifies the old experimental.dynamicIO/ppr/useCache), use the "use cache" directive for SEO-critical server components:
Built-in cacheLife profiles (stale / revalidate / expire): seconds (30s/1s/1m), minutes (5m/1m/1h), hours (5m/1h/1d), days (5m/1d/1w), weeks (5m/1w/30d), max (5m/30d/1y), and the implicit default (5m/15m/never). For SEO pages pick by how often content changes — days for blog/docs, max for legal/marketing. (minutes revalidates every 1 min — too aggressive for most SEO content.)
Key rules:
"use cache" must be the first statement in the function body (or at the top of the file for file-level caching)cookies()/headers()/searchParams inside a plain "use cache" scope — good for SEO, since indexable content should be request-agnostic. ("use cache: private" does allow them, but is never prerendered, so it never lands in the static SEO shell.)updateTag("hero") inside a Server Action (read-your-writes; it throws outside one), or revalidateTag("hero", "max") from a Route Handler / webhook (pass the profile — the one-argument form is legacy behaviour) — prefer these over export const revalidaterevalidate alone: choose a profile from the documented freshness requirements and verify the installed Next.js version's next build output. Prefer hours/days/max for SEO-critical content unless the product genuinely needs fresher data"use cache" (+ cacheTag) if they fetch CMS/dynamic data you want to invalidate on publish| Strategy | Use When | SEO Impact |
|---|---|---|
| "use cache" | Server components with periodic data | Best - cached HTML, fast TTFB |
| SSG (Static) | Content rarely changes | Best - pre-rendered HTML |
| SSR | Dynamic content per request | Great - server-rendered |
| CSR | Dashboards, authenticated areas | Poor - avoid for SEO pages |
| Metric | Target | Impact |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Loading speed |
| INP (Interaction to Next Paint) | < 200ms | Interactivity |
| CLS (Cumulative Layout Shift) | < 0.1 | Visual stability |
Metadata + CWV alone don't drive rankings. Keep these in mind (out of scope for this skill, but pointers):
generateMetadata, OG/icon files, ImageResponse, the manifest, or when streaming metadata / htmlLimitedBots is in playgenerateSitemaps, image/video sitemaps, multi-group robots rules, static robots.txt/sitemap.xml files@graph patternalternates.canonical when duplicate/parameterized URLs are a risk; it's a hint, not a requirement — Google may pick its own canonical/_next/ in robots.txt - Crawlers need render-critical CSS/JS; never disallow /_next/favicon.ico/icon.*/opengraph-image.* file conventions; they auto-emit tags and override the metadata objectGPTBot disallow: / blocks training but leaves you in AI search; don't accidentally block citation bots (OAI-SearchBot, PerplexityBot). See references/ai-search.mdkeywords meta tag for Google - Google ignores it entirely (no indexing or ranking effect); it's noise, not a signal* rules - Per RFC 9309 §2.2.1 the * group applies only when no group matches, and Google never merges a specific group with *. A { userAgent: 'OAI-SearchBot', allow: '/' } group drops the wildcard's /api///admin/ disallows — repeat them in every named group<title>/canonical (vercel/next.js #95406 — check its status on your version), and the browser view never shows it. Confirm production HTML with a bot User-Agent: curl -A "Googlebot" https://your-site.com | grep -E '<title>|canonical'◐ in the build output) can serve perfect SEO HTML while none of its <Suspense> boundaries hydrate on a direct load. Load the route directly in a browser and interact with it; the observation and the check are in references/troubleshooting.md.import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://your-site.com';
const posts = await getPosts(); // your CMS/DB
return [
{
url: baseUrl,
images: [`${baseUrl}/og-image.png`], // Image Sitemap entry
},
{ url: `${baseUrl}/about` },
...posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt, // real content timestamp
})),
];
}import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://your-site.com';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
// Do NOT disallow /_next/ — crawlers need render-critical CSS/JS
// Do NOT add bot-specific rules (Googlebot, Bingbot) unless overriding wildcard —
// and if you do, repeat all disallows: named groups don't inherit `*` rules
// (RFC 9309 §2.2.1; Google never merges a specific group with `*`)
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}// app/(home)/sections/hero-section.tsx
import { cacheLife, cacheTag } from "next/cache";
export async function HeroSection() {
"use cache";
cacheLife("hours"); // SEO content that changes a few times/day; see profiles below
cacheTag("hero"); // Invalidate via updateTag("hero") in a Server Action
const data = await fetchData();
return <div>{/* SEO-visible content */}</div>;
}export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params; // params is a Promise in current Next.js
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
};
}type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
return {
alternates: {
canonical: `/products/${slug}`,
},
};
}