Crawlable Pagination Without rel=next/prev in Headless

Now that Google no longer uses rel=next/prev as an indexing signal, crawlable pagination in a headless stack depends entirely on clean per-page URLs, self-referencing canonicals, and server-rendered links.

When to Use This Approach

Apply this pattern when your headless build exhibits any of the following:

  • You operate a large paginated archive (blog index, category listing, news feed) where deeper pages hold content that must remain discoverable to Googlebot.
  • Your pagination previously relied on rel=next/prev link elements to communicate sequence, and you need a strategy that works now that the signal is retired.
  • Your listing renders pagination controls as JavaScript-only buttons, so the links to page 2 and beyond never appear in the server HTML that crawlers parse first.

Crawlable pagination link graph Three paginated URLs connected by server-rendered anchor links, each declaring a self-referencing canonical that points to itself rather than to page one. Page 1 /archive Page 2 /archive/page/2 Page N /archive/page/N <a href> <a href> self-canonical self-canonical self-canonical Each page canonicalizes to itself, not to page 1

Implementation Steps

Step 1: Route Pages Through Path Segments

Serve every page of a listing from a dedicated path-based route (/archive/page/2) rather than a query parameter (?page=2). Path segments produce a single unambiguous URL per page, cache cleanly at the edge, and sidestep the duplicate-variant problem that query strings invite.

// app/archive/page/[page]/page.tsx — Next.js App Router
import { notFound } from 'next/navigation';

const PER_PAGE = 20;

export async function generateStaticParams() {
  const total = await getPostCount();
  const pages = Math.ceil(total / PER_PAGE);
  // Skip page 1 — that lives at /archive
  return Array.from({ length: pages - 1 }, (_, i) => ({
    page: String(i + 2),
  }));
}

export default async function ArchivePage({
  params,
}: {
  params: { page: string };
}) {
  const page = Number(params.page);
  if (!Number.isInteger(page) || page < 2) notFound();

  const posts = await getPosts({ page, perPage: PER_PAGE });
  if (posts.length === 0) notFound();

  return <PostList posts={posts} page={page} />;
}

Validation: curl -s -o /dev/null -w "%{http_code}\n" https://staging.yourdomain.com/archive/page/2 must return 200, and a non-existent page such as /archive/page/9999 must return 404.


Step 2: Emit a Self-Referencing Canonical Per Page

Each paginated URL must declare a canonical that points to itself. A common mistake is canonicalizing every page back to page one, which tells Google to drop pages 2+ from the index and, with them, the links to your deeper content.

// app/archive/page/[page]/page.tsx — metadata
import type { Metadata } from 'next';

const BASE = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. https://yourdomain.com

export async function generateMetadata({
  params,
}: {
  params: { page: string };
}): Promise<Metadata> {
  const page = Number(params.page);
  return {
    alternates: {
      canonical: `${BASE}/archive/page/${page}/`,
    },
  };
}

This is the same discipline covered in canonical URL enforcement: the canonical must match the served URL exactly, including trailing-slash policy.

Validation:

curl -s https://staging.yourdomain.com/archive/page/2/ \
  | grep -oP '(?<=rel="canonical" href=")[^"]+'
# Expected: https://yourdomain.com/archive/page/2/  (points to itself)

Googlebot discovers deeper pages by following <a href> links in the initial HTML. Render pagination controls as real anchors on the server, not as click handlers that fetch the next page via JavaScript.

// components/Pagination.tsx
export function Pagination({
  page,
  totalPages,
}: {
  page: number;
  totalPages: number;
}) {
  const href = (n: number) => (n === 1 ? '/archive' : `/archive/page/${n}`);
  return (
    <nav aria-label="Pagination">
      {page > 1 && <a href={href(page - 1)} rel="prev">Previous</a>}
      {page < totalPages && <a href={href(page + 1)} rel="next">Next</a>}
    </nav>
  );
}

Keeping rel="prev" and rel="next" is harmless for other user agents, but it is the crawlable href that does the real work now that Google ignores those relations for indexing.

Validation:

# Confirm the links exist in raw HTML with JS disabled (curl never runs JS)
curl -s https://staging.yourdomain.com/archive \
  | grep -oP '<a[^>]+href="/archive/page/\d+"'
# Must list the next-page anchor(s)

Step 4: Give Each Page a Unique Title and Description

If pages 2, 3, and 4 share the exact title and meta description of page one, Google may group them as duplicates. Append the page number so each URL is self-evidently distinct.

// app/archive/page/[page]/page.tsx — extend generateMetadata
export async function generateMetadata({
  params,
}: {
  params: { page: string };
}): Promise<Metadata> {
  const page = Number(params.page);
  return {
    title: `Archive — Page ${page}`,
    description: `Browse page ${page} of our full article archive.`,
    alternates: { canonical: `${BASE}/archive/page/${page}/` },
  };
}

Validation:

curl -s https://staging.yourdomain.com/archive/page/2/ \
  | grep -oP '<title>[^<]+</title>'
# Expected: <title>Archive — Page 2</title>

Step 5: Decide a noindex,follow Threshold for Deep Pages

Very deep archives can accumulate thousands of thin listing pages. Where a page offers no standalone value, apply noindex, follow beyond a threshold so the URL drops from the index while its links stay crawlable. This protects crawl budget without stranding deeper content.

// app/archive/page/[page]/page.tsx — conditional robots directive
const INDEX_THRESHOLD = 10;

export async function generateMetadata({
  params,
}: {
  params: { page: string };
}): Promise<Metadata> {
  const page = Number(params.page);
  return {
    alternates: { canonical: `${BASE}/archive/page/${page}/` },
    robots:
      page > INDEX_THRESHOLD
        ? { index: false, follow: true }
        : { index: true, follow: true },
  };
}

Validation:

curl -s https://staging.yourdomain.com/archive/page/25/ \
  | grep -oP '<meta name="robots" content="[^"]+"'
# Expected beyond threshold: <meta name="robots" content="noindex, follow">

SEO Impact Summary

Signal What improves What breaks if misconfigured
Discovery Server-rendered anchors let Googlebot reach every page without JS JS-only pagination hides deep content from the crawl
Canonical accuracy Self-referencing canonicals keep each page indexable Canonicalizing all pages to page 1 drops pages 2+ and their outbound links
Duplicate handling Per-page titles/descriptions signal distinct documents Shared titles collapse paginated URLs into one duplicate group
Crawl efficiency Path-based routes cache cleanly and avoid parameter variants Query strings spawn ?page=2&sort= duplicates that waste crawl budget

Measurable signals to watch:

  • GSC Pages report: paginated URLs should move to “Indexed” (for pages under your threshold) rather than “Crawled - currently not indexed”.
  • GSC Crawl stats: total crawled URLs should not balloon with parameter permutations after launch.
  • Log analysis: Googlebot hit depth should reach your deepest indexable page within a few crawl cycles.

Edge Cases and Gotchas

Preview environments and staging pagination A preview deployment that exposes the same /archive/page/N routes can leak into discovery if it is linked or listed anywhere. Block preview subdomains at the edge and ensure your sitemap only emits production paginated URLs.

Sort and filter parameters on top of pagination Faceted controls layered over pagination (/archive/page/2?sort=new) multiply URL variants fast. Canonicalize filtered views to their unfiltered path-based equivalent, or noindex the parameterized combinations, so the crawler concentrates on the canonical page sequence. See pagination SEO best practices for headless APIs for API-side patterns that keep offsets stable.

Incremental builds and shifting page boundaries With static or incremental generation, publishing new items shifts which post lands on which page. An item at the bottom of page 2 can migrate to page 3 after a publish. Trigger a rebuild or revalidation of affected pagination routes on publish so cached page contents stay accurate.

Off-by-one on the first page Page one usually lives at the bare listing path (/archive), not /archive/page/1. If both resolve, pick one and 301-redirect the other, otherwise you create a duplicate pair. Keep generateStaticParams starting at page 2 as shown above.

Frequently Asked Questions

Does Google still use rel=next/prev? No. Google confirmed in 2019 that it stopped using rel=next/prev as an indexing signal. The markup is harmless to keep for browsers and other user agents, but it no longer helps Google understand a paginated sequence. Crawlability now comes entirely from real server-rendered anchor links and clean, self-canonical per-page URLs.

Should page 2+ be noindex? Not by default. Leaving shallow pages indexable preserves the discovery of their linked content and any standalone relevance the listing carries. Apply noindex, follow only to very deep pages that offer no unique value, and never remove the crawlable links themselves — follow keeps discovery intact even when the page is dropped from the index.

Path segments or query params for pages? Prefer path segments such as /archive/page/2. Path-based URLs are unambiguous, cache cleanly at the edge, and avoid the parameter-handling quirks that let variants like ?page=2&sort=new fragment crawl budget. Query parameters still function, but they demand extra canonical and parameter-handling discipline to stay clean.

What about a “view all” page? A single view-all page can work for moderately sized listings and consolidates ranking signals onto one URL. For large archives it becomes too heavy to render and hurts Core Web Vitals, so keep paginated URLs as the crawlable path and reserve view-all for smaller sets.


Part of: Pagination Handling in Headless

Related