Next.js SEO skill for Codex
Metadata, sitemaps and canonicals the App Router way. Codex wires SEO through the App Router's own Metadata API and file conventions instead of legacy packages, with Next.js 15 and 16 Promise-based params handled correctly. It sets canonicals and robots per route type, splits large sitemaps with generateSitemaps, keeps content in server HTML, and proves the result with curl against next build and start.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- nextjs-seo Installs to ~/.agents/skills/nextjs-seo/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install nextjs-seo The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project nextjs-seo Installs to .agents/skills/nextjs-seo/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/nextjs-seo.zipunzip -o nextjs-seo.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) nextjs-seo PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $nextjs-seo, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements SEO in Next.js App Router projects — the Metadata API (metadata, generateMetadata, title templates, metadataBase, alternates.canonical, robots), app/sitemap.ts and generateSitemaps, app/robots.ts, opengraph-image routes, JSON-LD in server components, rendering choices and streaming metadata. Use when titles, canonicals or OG images are wrong in a Next.js app, when adding sitemap or robots routes, when pages render client-only, or when upgrading to Next.js 15/16 breaks metadata.
Use it when
- Titles, descriptions or canonicals are wrong in a Next.js App Router site
- Adding sitemap, robots or OG image routes to app/
- Upgrading to Next.js 15 or 16 broke generateMetadata or sitemap types
- Pages render content only on the client and are not indexed
Use something else when
- You need routing, layouts or loading states rather than SEO: use nextjs-app-router
- Pages are indexed but slow on Core Web Vitals: use core-web-vitals
How to check the result
On next build and next start, curl sample routes and confirm title, canonical, description and og:image in raw HTML, sitemap.xml lists only canonical URLs, and unknown slugs return 404.
The mistake it prevents
Setting alternates.canonical in a shared layout, which makes every child page canonicalize to the layout URL.
Done well: every indexable route ships its title, description, canonical, robots
directive, OG image and JSON-LD in server-rendered HTML; sitemap and robots come from the
same data as the routes; and a production build proves it with curl, not with the
browser tab title.
Before changing anything
- Read
AGENTS.md,package.json(Next.js version),next.config.*, and the rootapp/layout.tsx. Next.js 15+ passesparams/searchParamsas Promises; Next.js 16 also passes the sitemapidand OG imageparamsas Promises. Code written for 14 breaks silently or at type check. - Find existing SEO code:
grep -rnE "generateMetadata|export const metadata|next-seo|next-sitemap|robots\.(ts|js)|sitemap\.(ts|js)" app src --include='*.ts*' next-seoandnext-sitemapare Pages Router era tools. In the App Router prefer the built-in Metadata API and file conventions; remove the old package once replaced.- Pages Router projects (
pages/) usenext/headandgetServerSideProps; this skill targetsapp/. Migrations belong tonextjs-pages-to-app-migration.
Workflow
- [ ] 1. Root layout: metadataBase, title template, default description, OG defaults
- [ ] 2. Route metadata: static object or generateMetadata per segment
- [ ] 3. Canonicals and robots per route type
- [ ] 4. app/sitemap.ts (+ generateSitemaps when large) and app/robots.ts
- [ ] 5. OG images (file or opengraph-image.tsx)
- [ ] 6. JSON-LD in server components
- [ ] 7. Rendering check: content in server HTML
- [ ] 8. Build and verify with curl
1. Root layout
// app/layout.tsx
import type { Metadata } from "next";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.example.com";
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { default: "Example: invoicing for freelancers", template: "%s | Example" },
description: "Send invoices, track payments and chase late clients automatically.",
openGraph: { type: "website", siteName: "Example", locale: "en_US" },
twitter: { card: "summary_large_image" },
};
metadataBasemakes relative URLs inalternates,openGraph.imagesetc. absolute. Without it, relative URL fields fail the build. Set it from an env var so previews do not emit production canonicals pointing at preview hosts, or vice versa.title.templateapplies to child segments only, and requiresdefault.
2. Route metadata
Static pages export metadata; dynamic pages export generateMetadata (never both in
one segment). Fetches are memoized with the page’s own fetch, so do not hand-roll a cache.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return {
title: post.seoTitle ?? post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: { type: "article", publishedTime: post.publishedAt, authors: [post.author.name] },
robots: post.isDraft ? { index: false, follow: false } : undefined,
};
}
Metadata merges shallowly: a child that sets openGraph replaces the whole parent
openGraph object. Share defaults through a helper and spread them.
3. Canonicals and robots per route type
| Route | canonical | robots |
|---|---|---|
| Content page | itself, no query string | default (index) |
Paginated list ?page=2 | itself including ?page=2 | index |
| Sort/filter variants | the unfiltered URL | index (canonical handles it) or noindex if thin |
Internal search /search?q= | omit | { index: false, follow: true } |
| Auth, account, checkout | omit | { index: false } |
| Preview/staging deployments | n/a | X-Robots-Tag: noindex header via next.config headers() keyed on env |
Trailing slashes: pick trailingSlash in next.config once and make canonicals match;
Next.js redirects the other form with a 308.
4. Sitemap and robots routes
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { listPublishedPosts } from "@/lib/posts";
const base = process.env.NEXT_PUBLIC_SITE_URL!;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await listPublishedPosts();
return [
{ url: `${base}/`, lastModified: new Date(process.env.SITE_UPDATED_AT ?? Date.now()) },
...posts.map((p) => ({ url: `${base}/blog/${p.slug}`, lastModified: p.updatedAt })),
];
}
Over 50,000 URLs, split with generateSitemaps in a segment sitemap. In Next.js 16 the
id arrives as a Promise of a string:
// app/products/sitemap.ts -> /products/sitemap/0.xml, /products/sitemap/1.xml ...
import type { MetadataRoute } from "next";
import { countProducts, productsPage } from "@/lib/products";
const PER_FILE = 50_000;
export async function generateSitemaps() {
const pages = Math.ceil((await countProducts()) / PER_FILE);
return Array.from({ length: pages }, (_, id) => ({ id }));
}
export default async function sitemap(props: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id);
const rows = await productsPage(id * PER_FILE, PER_FILE);
return rows.map((r) => ({ url: `${process.env.NEXT_PUBLIC_SITE_URL}/p/${r.slug}`, lastModified: r.updatedAt }));
}
List those child sitemap URLs in app/robots.ts (Next.js does not build an index for
you). Use sitemaps-robots for rule design.
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_SITE_URL!;
if (process.env.VERCEL_ENV && process.env.VERCEL_ENV !== "production") {
return { rules: { userAgent: "*", disallow: "/" } };
}
return {
rules: [{ userAgent: "*", allow: "/", disallow: ["/api/", "/account/", "/search"] }],
sitemap: [`${base}/sitemap.xml`, `${base}/products/sitemap/0.xml`],
};
}
Do not disallow /_next/: Googlebot needs those JS and CSS files to render.
5. OG images
Static file app/opengraph-image.png (1200×630) for the default; per-route generated
images with ImageResponse from next/og:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Blog post cover";
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
<div style={{ display: "flex", width: "100%", height: "100%", padding: 64, fontSize: 64, background: "#fff" }}>
{post?.title ?? "Example"}
</div>,
size,
);
}
File-based metadata overrides openGraph.images from generateMetadata.
6. JSON-LD
Render in the page (server component), from the same object the page displays. Escape
< to prevent script injection. Type design belongs to schema-markup.
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>
7. Rendering
- Primary content, internal links and headings must be in the server HTML. A page that
is
"use client"at the top and fetches inuseEffectships an empty shell; move data fetching to the server component and pass props down. - Use
<Link href>(renders<a href>), neverrouter.pushinonClickfor navigation that should be crawlable. - Since 15.2,
generateMetadatathat waits on slow data streams its tags into<body>for JS-capable bots (Googlebot handles this); HTML-limited bots such as social scrapers still get it in<head>. If a non-Google consumer misses tags, sethtmlLimitedBotsinnext.configto include it rather than disabling streaming globally. notFound()ingenerateMetadataor the page must return a real 404 status; check it.
8. Verify on a production build
npm run build && npm run start # or the package manager from the lockfile
curl -s localhost:3000/blog/some-post | grep -oE '<title>[^<]*|<link rel="canonical"[^>]*>|<meta name="(description|robots)"[^>]*>|<meta property="og:image"[^>]*>'
curl -s localhost:3000/sitemap.xml | head -20
curl -s localhost:3000/robots.txt
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/blog/does-not-exist # expect 404
Dev mode (next dev) renders differently (no static optimization, sitemap paths may
differ before v15); always verify on build + start. Run the type check, since the
Promise-typed params errors show up there.
Gotchas
- Canonical built from
headers().get('host')makes every route dynamic and leaks preview hosts; build it from a configured site URL. metadatacannot be exported from a"use client"file. Keeppage.tsxa server component and move interactivity into a child client component.alternates.canonicalset in a layout is inherited by every child that does not set its own, so all pages canonicalize to the layout URL. Set canonicals at page level.robots: { index: false }in a root layout during a soft launch is easy to forget; gate it on an env var and add a test.- Middleware/proxy redirects (Next.js 16 renamed
middleware.tstoproxy.ts) must be permanent (308/301) for moved URLs;NextResponse.redirectdefaults to 307.
Output
**Changed:** app/layout.tsx (metadataBase from NEXT_PUBLIC_SITE_URL, title template),
app/blog/[slug]/page.tsx (generateMetadata with canonical + draft noindex),
app/sitemap.ts, app/robots.ts, app/blog/[slug]/opengraph-image.tsx.
**Removed:** next-seo and next-sitemap.
**Verified on next build/start:** canonical, description, og:image present in raw HTML
for 5 sample routes; /sitemap.xml lists 1,284 URLs; unknown slug returns 404; tsc clean. What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Next.js SEO"
short_description: "Metadata, sitemaps and canonicals the App Router way"
default_prompt: "$nextjs-seo Every page in our Next.js app has the same canonical, find out why and fix it" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.