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.

  • Growth & SEO
  • $nextjs-seo
  • 255 lines
  • 2 files
  • ~2,550 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- nextjs-seo

Installs to ~/.agents/skills/nextjs-seo/ for every project on this machine.

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

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.

~/.agents/skills/nextjs-seo/SKILL.md

--- name: nextjs-seo description: Implements SEO in Next.js App Router projects — the Metadata API (metadata, generateMetadata, title templates, metadataBase, alternates.canonical,… license: MIT ---

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 root app/layout.tsx. Next.js 15+ passes params/searchParams as Promises; Next.js 16 also passes the sitemap id and OG image params as 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-seo and next-sitemap are 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/) use next/head and getServerSideProps; this skill targets app/. Migrations belong to nextjs-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" },
};
  • metadataBase makes relative URLs in alternates, openGraph.images etc. 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.template applies to child segments only, and requires default.

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

Routecanonicalrobots
Content pageitself, no query stringdefault (index)
Paginated list ?page=2itself including ?page=2index
Sort/filter variantsthe unfiltered URLindex (canonical handles it) or noindex if thin
Internal search /search?q=omit{ index: false, follow: true }
Auth, account, checkoutomit{ index: false }
Preview/staging deploymentsn/aX-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 in useEffect ships an empty shell; move data fetching to the server component and pass props down.
  • Use <Link href> (renders <a href>), never router.push in onClick for navigation that should be crawlable.
  • Since 15.2, generateMetadata that 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, set htmlLimitedBots in next.config to include it rather than disabling streaming globally.
  • notFound() in generateMetadata or 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.
  • metadata cannot be exported from a "use client" file. Keep page.tsx a server component and move interactivity into a child client component.
  • alternates.canonical set 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.ts to proxy.ts) must be permanent (308/301) for moved URLs; NextResponse.redirect defaults 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem