agentleFS
keuperakeupera/seo-skills/skills/nextjs-seo/SKILL.md

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

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
  1. Very specific:

    Every element in an ImageResponse needs an explicit display: flex when it has more than one child.

    line 157
  2. Distinctive:

    Verify by looking at what the server actually sends:

    line 199
  3. Distinctive:

    If the article text isn't in that output, no metadata work will save the page.

    line 206

“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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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.canonical on every page, as a path. Next resolves it against metadataBase. Without it, query strings and trailing-slash variants become separate indexable URLs.
  • params is a Promise in recent App Router versions. Await it.
  • Return {} and call notFound() for missing records. Don't emit metadata for a page that will 404.
  • generateMetadata and generateStaticParams are 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 uncached fetch on a marketing page means server rendering on every request, which is slower and pointless for content that changes weekly. Use static generation with revalidation.
  • generateStaticParams on dynamic routes, so the pages are pre-rendered instead of built on first request.
  • next/link renders real anchors. Routing via router.push in an onClick handler does not, and those links are invisible to crawlers.
  • next/image with explicit width, height and a real alt. Set priority on 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 1000

If 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 RouterPages Router
metadata / generateMetadatanext/head in the page, or a shared <Seo> component
app/sitemap.tspages/sitemap.xml.tsx with getServerSideProps
app/robots.tspublic/robots.txt
opengraph-image.tsxpages/api/og.tsx with ImageResponse
Server components by defaultgetStaticProps / getServerSideProps

The rendering advice is the same: get the content into the HTML the server sends.

Pre-launch checklist

  • metadataBase set
  • Unique title and description on every route
  • alternates.canonical on every route
  • robots.ts not blocking anything real, and not left in staging state
  • sitemap.ts covering all indexable routes and nothing else
  • OG image renders and previews correctly
  • Content present in curl output without JavaScript
  • One h1 per page
  • Trailing slash behaviour decided and consistent
  • noindex removed 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

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.