---
name: nextjs-programmatic-seo
description: Generate hundreds of SEO-optimized pages from a dataset (database, API, or file) using the Next.js 16 App Router — choosing a rendering strategy (static, ISR, or dynamic) and wiring up dynamic routes, generateMetadata, OG images, sitemap, and Cache Components. Use when the user wants programmatic SEO, pSEO, templated landing pages, or a page-per-record site in Next.js.
---

# Next.js Programmatic SEO

Build one page template, then serve an SEO-optimized page for every record in a dataset. Adapt every step to the user's actual data source, schema, and routing — names below are illustrative.

## What SEO actually requires (the constants)

These hold no matter how you render the page:

- **Server-rendered HTML** crawlers can read without running JS.
- **Per-page metadata** via `generateMetadata` (title, description, OG, Twitter).
- **A sitemap** listing every page.
- **Internal links** so crawlers can discover pages.
- **Good performance** (Core Web Vitals are a ranking factor).

Note what is NOT on this list: being *static*. Static generation is usually the best default, but SEO needs server-rendered HTML — not necessarily prebuilt HTML. Choose the rendering strategy per use case (next section).

## Start with the strategy (before any code)

Programmatic SEO fails without a reason for people to visit. **Before any technical work, confirm the content strategy with the user:** who is searching, what for, and why they'd click through to *and engage with* these pages rather than reading the answer straight off Google (AI summaries now absorb many simple, zero-click queries). If the pages don't offer something worth visiting — a product to buy, options to compare, a tool to use — generating more of them won't help. Don't dive into the build until this is clear.

## Choose your data source

The simplest case is a static JSON file, but **in most real projects the data lives in a database or API**. Hide the source behind two functions so the rest of the implementation never changes:

```ts
// src/data/records.ts — swap only the bodies for your real source
import type { Record } from "./types"

export async function getAllRecords(): Promise<Record[]> {
  // Static JSON:   return (await import("./records.json")).records
  // Prisma:        return prisma.record.findMany()
  // Drizzle:       return db.select().from(records)
  // Convex:        return fetchQuery(api.records.list, {})
  // Supabase:      return (await supabase.from("records").select("*")).data ?? []
  // REST/GraphQL:  return fetch(`${process.env.API_URL}/records`).then((r) => r.json())
}

export async function getRecord(slug: string): Promise<Record | null> {
  // Small sets: filter in memory. Large sets: query by slug directly, e.g.
  //   Prisma: return prisma.record.findUnique({ where: { slug } })
  const all = await getAllRecords()
  return all.find((r) => r.slug === slug) ?? null
}
```

## Choose your rendering strategy

This is the key decision and it depends on the dataset and business needs — not on SEO alone. All three options below produce crawlable, server-rendered HTML.

| Strategy | Best when | How |
| --- | --- | --- |
| **Static (SSG)** — default | Finite, enumerable, fairly stable dataset (up to ~tens of thousands) | `generateStaticParams` returns every slug; pages baked at build |
| **Incremental (ISR)** | Large or growing dataset; you don't want to build everything upfront | `generateStaticParams` returns some/none + `dynamicParams = true` + `revalidate` |
| **Fully dynamic (SSR)** | Millions of records, highly volatile data, personalization, or slugs you can't enumerate | Omit `generateStaticParams`; render per request (optionally `export const dynamic = "force-dynamic"`) |

Rules of thumb: prefer **Static** for speed and cost; move to **ISR** when the set is too big or changes often; go **fully dynamic** only when you genuinely can't prebuild. You can also mix — a static/ISR shell with a dynamic section via Cache Components (see below).

## Workflow

Settle the content strategy above first, then:

1. **Confirm the data source, schema, and slug field.** Ask where records come from and which field is the slug. Normalize slugs to lowercase, URL-safe strings.
2. **Pick the rendering strategy** (table above). Ask about dataset size and volatility if unclear — this shapes the rest.
3. **Create the data layer** (`getAllRecords` / `getRecord`) so the source is swappable.
4. **Create the dynamic route** at `src/app/<segment>/[slug]/page.tsx`.
5. **Apply the strategy:** add `generateStaticParams` for Static/ISR (plus `revalidate` for ISR), or omit it for fully dynamic.
6. **Look up the record** with `getRecord(slug)`; render a not-found state if null.
7. **Build the template** from real fields. Compose copy that answers real search queries. Quality drives ranking — push for genuinely useful copy, not thin pages.
8. **Add `generateMetadata`** (required for every strategy).
9. **Add `opengraph-image.tsx`** in the `[slug]` folder using `next/og` `ImageResponse` (style with `tw=""`, not `className`).
10. **Add a dynamic `sitemap.ts`** emitting one entry per record plus key static pages, using the real production base URL.
11. **Add a home/index page** linking to generated pages — internal linking helps crawlers.
12. **Deploy** (Vercel recommended) and confirm the production base URL in `sitemap.ts`.

## Core patterns

The example below shows the **Static** default. For **ISR**, keep `generateStaticParams` and add `export const revalidate = 3600`. For **fully dynamic**, delete `generateStaticParams` entirely — everything else stays the same.

```tsx
// src/app/<segment>/[slug]/page.tsx
import { getAllRecords, getRecord } from "@/data/records"

// Static / ISR only — omit this block for a fully dynamic route.
export const generateStaticParams = async () => {
  const records = await getAllRecords()
  return records.map((r) => ({ slug: r.slug }))
}
// export const revalidate = 3600 // add for ISR

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const record = await getRecord((await params).slug)
  const title = `What is ${record?.name}?`
  const description = `...build from record fields...`
  return {
    title, description,
    keywords: [record?.name /* ... */],
    openGraph: { title, description, siteName: "Site Name" },
    twitter: { title, description },
  }
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const record = await getRecord((await params).slug)
  if (!record) return <NotFound />
  return <Template record={record} />
}
```

```ts
// src/app/sitemap.ts
import { MetadataRoute } from "next"
import { getAllRecords } from "@/data/records"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = "https://www.example.com" // real production URL
  const records = await getAllRecords()
  return [
    { url: baseUrl, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
    ...records.map((r) => ({
      url: `${baseUrl}/<segment>/${r.slug}`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.8,
    })),
  ]
}
```

The sitemap should list pages even for dynamic routes — query the source for the slug list here too. For very large sets, use [multiple sitemaps](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generating-multiple-sitemaps).

## Build-time data & freshness

For **Static/ISR**, `generateStaticParams` and the page run at **build time** and bake the result into HTML, so the database/API must be reachable during `next build`. "Static" does not mean the data lives in a file — it means it's fetched once at build, not per request. To reflect later changes:

- **ISR (`export const revalidate = N`)** — rebuilds in the background every N seconds. Best default for DB/API-backed pSEO.
- **`dynamicParams = true`** (the default) — slugs not returned by `generateStaticParams` render on-demand the first time, then cache.
- **On-demand revalidation** (`revalidatePath` / `revalidateTag`) — trigger from a webhook when the source changes.

For **fully dynamic** routes this is moot — every request hits the source (cache deliberately if needed).

## Dynamic / per-request content (Cache Components)

Use when **part** of an otherwise static/ISR page is too fresh, large, or personalized to bake in (live prices, stock, a third-party API, user data). Keep the page a static shell and stream the dynamic part in (Partial Prerendering).

1. Enable in `next.config.ts`: `const nextConfig = { cacheComponents: true }`.
2. Put the fetch in an async child component marked `"use cache"` with `cacheLife("days")` (also `"hours"`, `"weeks"`, `"max"`). The cache key derives from props, so each record caches separately.
3. Wrap it in `<Suspense fallback={<Skeleton />}>` so the shell renders instantly while the dynamic part streams in.

```tsx
async function Enrichment({ id }: { id: string }) {
  "use cache"
  cacheLife("days")
  const data = await fetch(/* external source */).then((r) => r.json()).catch(() => null)
  if (!data) return null
  return <section>{/* ...render... */}</section>
}
// In the page: <Suspense fallback={<Skeleton />}><Enrichment id={record.id} /></Suspense>
```

`next build` marks PPR pages with `◐` (static shell + streamed dynamic content).

## Verification

- [ ] Pages render server HTML (view source shows content without JS) under the chosen strategy.
- [ ] Static/ISR: `next build` generates the expected pages. Dynamic: pages render fresh per request.
- [ ] Visiting a known slug renders the templated page; an unknown slug renders the not-found state.
- [ ] Page `<head>` contains the per-page title/description/OG/Twitter tags.
- [ ] `/<segment>/<slug>/opengraph-image` returns a PNG.
- [ ] `/sitemap.xml` lists every record URL with the production base URL.
- [ ] Home page links to the generated pages.
- [ ] If using a live source for Static/ISR, confirm it's reachable during `next build`.

## Documentation

Read these for depth (Next.js APIs change between major versions — if a path 404s, search the official docs for the current location):

- Dynamic routes: https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes
- generateStaticParams: https://nextjs.org/docs/app/api-reference/functions/generate-static-params
- generateMetadata: https://nextjs.org/docs/app/api-reference/functions/generate-metadata
- Rendering & route segment config (static/dynamic): https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
- ISR / `revalidate`: https://nextjs.org/docs/app/guides/incremental-static-regeneration
- OG image generation: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image
- sitemap.ts: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap
- `use cache` directive: https://nextjs.org/docs/app/api-reference/directives/use-cache
- Partial Prerendering: https://nextjs.org/docs/app/getting-started/partial-prerendering

## Reference

- Full human tutorial: https://www.ktz.dev/nextjs-programmatic-seo-guide
- Example repo: https://github.com/ben-katz/atomic-chart
