10 min read

Programmatic SEO with the Next.js App Router

How to build programmatic SEO pages in the Next.js App Router from one typed data file: generateStaticParams, dynamicParams, metadata, sitemap.ts, crawlable links, FAQ JSON-LD and tests that keep the pages honest.

What programmatic SEO means for a small SaaS

Programmatic SEO is building many pages from one template and one dataset, where each page answers a query you could never cover by hand: "X for Next.js", "X vs Y", "X in Hugo". For a small SaaS, that usually means a few dozen pages, not a few hundred thousand: one page per integration, per competitor, per use case or per framework you support.

The Next.js App Router makes the mechanics easy. A dynamic route, generateStaticParams, generateMetadata and a sitemap.ts file get you from a data file to statically rendered, indexable pages in an afternoon. The hard part is not the code. It is making sure every page says something specific and true, because a template filled with a swapped keyword is exactly what Google's spam policies describe.

This guide walks through the pattern we use on blogger-dog.com for our integration pages and comparison pages: one typed data file drives the route, the index page, the sitemap, the footer links and the FAQ structured data, and a handful of tests stop the pages from drifting away from the truth. The examples use current Next.js syntax; notes call out where Next.js 13 and 14 differ.

Start with the data, not the template

The quality of a programmatic page is set by the data behind it. If the only field that changes between pages is the name, every page is a near-duplicate and you have built a doorway. Before writing a route, decide which fields carry real, page-specific information.

For our integration pages, each stack has its own detection signals, content folders, file format, setup steps, caveats and frequently asked questions. That is the test we apply to any new dataset: could a reader of the Hugo page learn something they could not learn from the Astro page? If not, the page should not exist.

Here is a trimmed version of the type:


// src/lib/integrations.ts

export type Integration = {

  slug: string;            // URL segment: /integrations/[slug]

  name: string;            // "Next.js"

  title: string;           // <title> without the site suffix, under 60 chars

  description: string;     // meta description, 80 to 170 chars

  intro: string;           // first paragraph, specific to the stack

  detectedBy: string[];    // files that identify the stack in a repo

  contentPaths: string[];  // where posts usually live

  steps: { title: string; body: string }[];

  faq: { question: string; answer: string }[];

};



export const INTEGRATIONS: Integration[] = [

  {

    slug: "hugo",

    name: "Hugo",

    title: "Automated SEO Blog Posts for Hugo",

    description: "How posts are written into a Hugo site: Markdown with YAML front matter in your content section, built by your existing pipeline.",

    intro: "Hugo reads Markdown with front matter from a section of the content directory ...",

    detectedBy: ["hugo.toml / hugo.yaml", "config.toml / config.yaml at the repo root"],

    contentPaths: ["content/posts", "content/blog"],

    steps: [/* ... */],

    faq: [/* three questions only a Hugo user would ask */],

  },

  // one object per page

];



export function getIntegration(slug: string) {

  return INTEGRATIONS.find((item) => item.slug === slug);

}

Two habits make this file trustworthy. First, a comment at the top says where each claim was checked and when, for example "verified against the backend publishing code on 2026-10-15". Second, anything that changes often, such as a competitor's price, gets a CHECKED_ON date that is rendered on the page. If you cannot say where a fact came from, leave it out.

Keep the data in the repo as TypeScript or JSON rather than in a database. It is versioned, reviewable in a pull request, available at build time, and the type checker catches a page that is missing a field.

The dynamic route

The route file turns each object into a static page. Three exports do the work: generateStaticParams lists the pages, dynamicParams = false makes everything else a 404, and generateMetadata gives each page its own title, description and canonical.


// app/integrations/[slug]/page.tsx

import type { Metadata } from "next";

import { notFound } from "next/navigation";

import { INTEGRATIONS, getIntegration } from "@/lib/integrations";



type Props = { params: Promise<{ slug: string }> };



// Only slugs in the data file exist. /integrations/anything-else is a 404.

export const dynamicParams = false;



export function generateStaticParams() {

  return INTEGRATIONS.map((item) => ({ slug: item.slug }));

}



export async function generateMetadata({ params }: Props): Promise<Metadata> {

  const { slug } = await params;

  const item = getIntegration(slug);

  if (!item) return {};

  const path = `/integrations/${item.slug}`;

  return {

    title: item.title,

    description: item.description,

    alternates: { canonical: path },

    openGraph: { title: item.title, description: item.description, url: path },

  };

}



export default async function IntegrationPage({ params }: Props) {

  const { slug } = await params;

  const item = getIntegration(slug);

  if (!item) notFound();



  return (

    <article>

      <h1>{item.title}</h1>

      <p>{item.intro}</p>

      <h2>Where posts go</h2>

      <ul>{item.contentPaths.map((p) => <li key={p}><code>{p}</code></li>)}</ul>

      <h2>Setup</h2>

      <ol>{item.steps.map((s) => <li key={s.title}><strong>{s.title}.</strong> {s.body}</li>)}</ol>

    </article>

  );

}

According to the Next.js generateStaticParams reference, returning the full list renders every page at build time, and with dynamicParams set to false only those paths are served; anything else returns a 404. That matters for SEO: without it, a typo or a scraped link to /integrations/wordpres would render an empty template with a 200 status, which is a soft 404 waiting to happen.

On Next.js 13 and 14, params is a plain object, so you write params.slug without await. The rest is identical.

Set metadataBase once in the root layout so relative canonicals and Open Graph URLs resolve to your production domain. Every programmatic page needs its own canonical; a template that inherits the homepage canonical tells Google all of them are copies of /. Our Next.js blog SEO checklist covers that and the other metadata traps in more detail.

Add them to the sitemap from the same file

A page that only exists in your route is easy for crawlers to miss. Generate the sitemap entries from the same array so a new object adds a URL automatically:


// app/sitemap.ts

import type { MetadataRoute } from "next";

import { INTEGRATIONS } from "@/lib/integrations";



const SITE = "https://example.com";



export default function sitemap(): MetadataRoute.Sitemap {

  return [

    { url: `${SITE}/integrations` },

    ...INTEGRATIONS.map((item) => ({

      url: `${SITE}/integrations/${item.slug}`,

      // Only set lastModified when you know it, e.g. a checkedOn field.

      ...(item.checkedOn ? { lastModified: item.checkedOn } : {}),

    })),

  ];

}

Two details from Google's sitemap documentation are worth knowing. Google ignores priority and changefreq, so do not spend time tuning them. And it uses lastmod only when the value is consistently and verifiably accurate. A common Next.js mistake, which this site made until today, is lastModified: new Date() as a fallback: every build then claims every page changed today, which teaches Google to ignore the field. Leave it out when you do not have a real date.

A single sitemap holds up to 50,000 URLs. A small SaaS will never hit that, but if your dataset is large, Next.js supports generateSitemaps to split it.

Link the pages in server-rendered HTML

Sitemaps help discovery; links decide how important a page looks. Programmatic pages are often orphans, reachable only from the sitemap, and Google Search Console then reports them as "Discovered - currently not indexed" for weeks.

Give them at least three plain <a href> paths:

  • An index page at /integrations that lists every item with a one-line summary, generated from the same array.
  • The site footer, rendered on the server, linking each item. With next/link in a Server Component this is ordinary HTML in the initial response.
  • Cross-links between siblings, such as "Also works with Astro, Hugo and Jekyll" at the bottom of each page.

Check the result with curl -s https://example.com/ | grep -o 'href="/integrations/[^"]*"'. If the links only appear after JavaScript runs, for example inside a client-side menu that opens on click, treat them as missing.

Link from your blog posts too. A post about Astro content collections that links the Astro integration page passes both relevance and a crawl path. That is why every post on this blog links two or more sibling pages in the body, and a test enforces it.

Add structured data that matches the page

If each page has a real FAQ section, mark it up with FAQPage JSON-LD built from the same faq array that renders the visible questions. Do not add markup for content that is not on the page.


function faqJsonLd(faq: { question: string; answer: string }[]) {

  return {

    "@context": "https://schema.org",

    "@type": "FAQPage",

    mainEntity: faq.map(({ question, answer }) => ({

      "@type": "Question",

      name: question,

      acceptedAnswer: { "@type": "Answer", text: answer },

    })),

  };

}



// inside the page component

<script

  type="application/ld+json"

  dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd(item.faq)) }}

/>

Be realistic about the payoff. Since 2023 Google has shown FAQ rich results mainly for well-known government and health sites, so do not expect the accordion in search results for a SaaS page. The value is elsewhere: question-and-answer pairs in plain HTML are easy for answer engines to quote, and the markup costs nothing once it is generated from data you already have. A BreadcrumbList (Home, Integrations, Hugo) is also cheap and does appear in results.

Stay on the right side of scaled content abuse

Google's spam policies define scaled content abuse as generating many pages "for the primary purpose of manipulating search rankings" rather than helping users. The examples include generating many pages without adding value, stitching content from other pages together, and pages that make little sense to a reader but contain search keywords. The policy applies whether the pages are written by people, templates or AI. The same page also covers doorway abuse: pages built to rank for similar queries that all funnel users to the same place.

For a small SaaS, the practical rules are:

  • One page per thing you actually support. If you do not integrate with WordPress, there is no WordPress page. We left one competitor out of our comparison pages entirely because we could not find an official site to source facts from.
  • Each page must contain information the others do not. Different file paths, different caveats, different questions. If you would be embarrassed to have a user read two of your pages side by side, merge them.
  • Source and date volatile facts. Competitor prices and features change. Store the source URLs and the date you checked them in the data, render the date, and re-check on a schedule.
  • Say when someone else is the better fit. A comparison page that lists only your strengths reads as an ad, to users and to quality raters alike.
  • Start small. Ship 5 to 10 pages, watch Search Console for a few weeks, and only expand the dataset once those pages are indexed and getting impressions.

Google's guidance on creating helpful, people-first content asks whether a page provides original information and substantial value compared with other pages in search results. That is a good final review question for every row you add to the data file.

Tests that keep the pages honest

The failure mode of programmatic SEO is drift: someone adds a row with a 90-character title, forgets the FAQ, or removes the footer link during a redesign. A few tests with Node's built-in test runner catch that without any extra dependencies:


// tests/integrations.test.mjs

import assert from "node:assert/strict";

import { readFile } from "node:fs/promises";

import test from "node:test";

import { INTEGRATIONS } from "../src/lib/integrations.ts";



test("every page has unique slugs, SERP-sized titles and real descriptions", () => {

  const slugs = new Set();

  for (const item of INTEGRATIONS) {

    assert.ok(!slugs.has(item.slug), `duplicate slug ${item.slug}`);

    slugs.add(item.slug);

    assert.ok(item.title.length <= 60, `${item.slug}: title too long`);

    assert.ok(item.description.length >= 80 && item.description.length <= 170);

  }

});



test("FAQ questions are unique across pages", () => {

  const seen = new Set();

  for (const item of INTEGRATIONS) {

    assert.equal(item.faq.length, 3, `${item.slug} needs 3 FAQ items`);

    for (const { question } of item.faq) {

      assert.ok(!seen.has(question), `"${question}" is duplicated`);

      seen.add(question);

    }

  }

});



test("pages are in the sitemap and the server-rendered footer", async () => {

  const footer = await readFile(new URL("../src/components/site-footer.tsx", import.meta.url), "utf8");

  assert.match(footer, /\/integrations\/\$\{integration\.slug\}/);

});

The unique-question test is the cheap version of a duplicate-content check. If two pages can share an FAQ question, the pages are probably too similar.

After deploying, request indexing for the index page in Search Console and let the links do the rest. Then watch the Pages report: new programmatic URLs should move from "Discovered" to indexed within a few weeks if they are linked and distinct. If they stay discovered but not indexed, Google is telling you it does not see enough value yet, and the fix is better data, not more pages.

A checklist before you ship

  • Every row has fields that differ meaningfully from its siblings.
  • generateStaticParams returns every slug and dynamicParams is false.
  • Each page has its own title, description and self-referencing canonical.
  • The sitemap is generated from the same data, with lastModified only where it is real.
  • An index page, the footer and sibling pages link every item in server-rendered HTML.
  • Structured data is built from the same arrays as the visible content.
  • Tests fail on duplicate slugs, oversized titles, missing FAQs and missing links.
  • Volatile facts carry a source and a checked-on date.

FAQ

How many programmatic pages should a small SaaS start with?

As many as you have genuinely distinct things to say about, which is usually 5 to 20. Integrations, competitors and core use cases are the natural first datasets. Expand only after the first batch is indexed and earning impressions.

Should programmatic pages be static or server-rendered?

Static, whenever the data is in the repo. generateStaticParams builds them at deploy time, they load fast, and there is no runtime dependency that can fail. Use incremental static regeneration only if the data lives in an external API and changes often.

Can I use AI to write the page content?

The policy is about value, not method. AI can help draft, but every claim should be checked against a source you control, such as your product code or docs, or a page you fetched and dated. Unreviewed generated text at scale is the pattern the scaled content abuse policy describes.

Do programmatic pages help with AI answers as well as Google?

They can, if they answer specific questions in plain, server-rendered text. "Which folder does this tool write to in a Hugo site?" is the kind of question an answer engine can quote from a page that states it directly. Our guide to llms.txt for developer blogs covers the separate question of describing your site to AI agents.

Pair your pages with fresh posts

Programmatic pages bring visitors in on specific queries; blog posts give them, and Google, a reason to come back and a stream of new internal links. Most developer-run blogs stall on the second part. Blogger Dog researches and writes cited SEO and AEO posts and commits them straight into your Next.js, Astro, Hugo, Jekyll, Gatsby or Docusaurus repo, in the format your site already reads, then checks that each post's public URL is live before counting it. See how it fits a Next.js repo on the Next.js integration page.

You get 3 posts free, then plans start at $19/month. See pricing and plans.

Put your blog on autopilot

Blogger Dog researches, writes and commits SEO/AEO posts like this one straight into your GitHub or GitLab repo on a schedule. Your first 3 posts are free, then plans start at $19/month.

Launch offer: 50% off any plan for 3 months with code EARLY3. First 3 customers, ends Sunday.

Claim 50% off

More from the archive