Skip to content

Commit d4f26a7

Browse files
authored
fix(seo): make content pagination indexable (#8651)
1 parent e8e946c commit d4f26a7

8 files changed

Lines changed: 37 additions & 35 deletions

File tree

‎.claude/rules/landing-seo-geo.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ paths:
1717
- All copy is server-rendered text: no text baked into images, no content that exists only after a client effect runs.
1818
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element). The navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
1919
- Structured data: emit JSON-LD (`Organization`, `WebSite`, `WebApplication` with `featureList`, `FAQPage` if an FAQ exists) from a server component rendered before visible content. Keep `featureList` in sync with the features the page shows (`apps/sim/app/(landing)/components/home-structured-data/`).
20+
- `/library` and `/blog` pagination stays server-rendered with real next/previous links. Each unfiltered page is indexable and uses its own canonical URL (`?page=2`, etc.); page 1 canonicalizes to the bare index. Tag-filtered variants remain `noindex,follow`. See [Google pagination guidance](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading).
2021
- After adding routes or anchors, verify `app/sitemap.ts` and `app/robots.ts` still reflect reality.
2122

2223
## GEO (Generative Engine Optimisation)

‎.cursor/rules/landing-seo-geo.mdc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ globs: ["apps/sim/app/(landing)/**/*.tsx","apps/sim/content/**/*.mdx"]
1717
- All copy is server-rendered text: no text baked into images, no content that exists only after a client effect runs.
1818
- Navbar is a Server Component (no `'use client'`) for immediate crawlability. Logo `<Image>` has `priority` (LCP element). The navbar `<nav>` carries `SiteNavigationElement` schema.org markup.
1919
- Structured data: emit JSON-LD (`Organization`, `WebSite`, `WebApplication` with `featureList`, `FAQPage` if an FAQ exists) from a server component rendered before visible content. Keep `featureList` in sync with the features the page shows (`apps/sim/app/(landing)/components/home-structured-data/`).
20+
- `/library` and `/blog` pagination stays server-rendered with real next/previous links. Each unfiltered page is indexable and uses its own canonical URL (`?page=2`, etc.); page 1 canonicalizes to the bare index. Tag-filtered variants remain `noindex,follow`. See [Google pagination guidance](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading).
2021
- After adding routes or anchors, verify `app/sitemap.ts` and `app/robots.ts` still reflect reality.
2122

2223
## GEO (Generative Engine Optimisation)

‎apps/sim/app/(landing)/blog/page.tsx‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
import type { Metadata } from 'next'
2+
import { notFound } from 'next/navigation'
23
import { getAllPostMeta } from '@/lib/blog/registry'
34
import { BLOG_SECTION, buildCollectionPageJsonLd, buildIndexMetadata } from '@/lib/blog/seo'
4-
import { selectVisiblePosts } from '@/lib/content/index-list'
5+
import { resolveContentPage, selectVisiblePosts } from '@/lib/content/index-list'
56
import { ContentIndexPage } from '@/app/(landing)/components'
67

7-
/**
8-
* Filtered/paginated variants render genuinely different lists, but only the
9-
* bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
10-
* for the shared noindex policy.
11-
*/
128
export async function generateMetadata({
139
searchParams,
1410
}: {
1511
searchParams: Promise<{ page?: string; tag?: string }>
1612
}): Promise<Metadata> {
1713
const { page, tag } = await searchParams
18-
const pageNum = Math.max(1, Number(page || 1))
14+
const posts = await getAllPostMeta()
15+
const pageNum = resolveContentPage(posts, { page, tag })
16+
if (pageNum === null) notFound()
1917
return buildIndexMetadata({ tag, pageNum })
2018
}
2119

@@ -25,8 +23,9 @@ export default async function BlogIndex({
2523
searchParams: Promise<{ page?: string; tag?: string }>
2624
}) {
2725
const { page, tag } = await searchParams
28-
const pageNum = Math.max(1, Number(page || 1))
2926
const posts = await getAllPostMeta()
27+
const pageNum = resolveContentPage(posts, { page, tag })
28+
if (pageNum === null) notFound()
3029

3130
return (
3231
<ContentIndexPage

‎apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ interface ContentIndexPageProps {
2222
* Shared index/list layout for a content section (blog or library): section
2323
* header, featured-post row, remaining posts list, and pagination. Both
2424
* sections render this exact layout, parameterized by `basePath` and copy —
25-
* see `.claude/rules/landing-seo-geo.md` for the filtered/paginated noindex
26-
* policy this pairs with (`buildIndexMetadata` in `@/lib/content/seo`).
25+
* paginated URLs are indexable, while tag-filtered variants are noindexed
26+
* by `buildIndexMetadata` in `@/lib/content/seo`.
2727
*/
2828
export function ContentIndexPage({
2929
basePath,

‎apps/sim/app/(landing)/library/page.tsx‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
import type { Metadata } from 'next'
2-
import { selectVisiblePosts } from '@/lib/content/index-list'
2+
import { notFound } from 'next/navigation'
3+
import { resolveContentPage, selectVisiblePosts } from '@/lib/content/index-list'
34
import { getAllPostMeta } from '@/lib/library/registry'
45
import { buildCollectionPageJsonLd, buildIndexMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
56
import { ContentIndexPage } from '@/app/(landing)/components'
67

7-
/**
8-
* Filtered/paginated variants render genuinely different lists, but only the
9-
* bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
10-
* for the shared noindex policy.
11-
*/
128
export async function generateMetadata({
139
searchParams,
1410
}: {
1511
searchParams: Promise<{ page?: string; tag?: string }>
1612
}): Promise<Metadata> {
1713
const { page, tag } = await searchParams
18-
const pageNum = Math.max(1, Number(page || 1))
14+
const posts = await getAllPostMeta()
15+
const pageNum = resolveContentPage(posts, { page, tag })
16+
if (pageNum === null) notFound()
1917
return buildIndexMetadata({ tag, pageNum })
2018
}
2119

@@ -25,8 +23,9 @@ export default async function LibraryIndex({
2523
searchParams: Promise<{ page?: string; tag?: string }>
2624
}) {
2725
const { page, tag } = await searchParams
28-
const pageNum = Math.max(1, Number(page || 1))
2926
const posts = await getAllPostMeta()
27+
const pageNum = resolveContentPage(posts, { page, tag })
28+
if (pageNum === null) notFound()
3029

3130
return (
3231
<ContentIndexPage

‎apps/sim/lib/content/index-list.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,14 @@ export function selectVisiblePosts(
5555
const { featured, remaining } = paginateContentPosts(posts, options)
5656
return [...featured, ...remaining]
5757
}
58+
59+
/** Resolves an archive page number, rejecting invalid or empty pagination URLs. */
60+
export function resolveContentPage(
61+
posts: ContentMeta[],
62+
{ tag, page }: { tag?: string; page?: string }
63+
): number | null {
64+
const pageNum = Number(page || 1)
65+
if (!Number.isSafeInteger(pageNum) || pageNum < 1) return null
66+
const { totalPages } = paginateContentPosts(posts, { tag, page: pageNum })
67+
return pageNum <= totalPages ? pageNum : null
68+
}

‎apps/sim/lib/content/seo.ts‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,8 @@ export function buildPostGraphJsonLd(post: ContentMeta, section: ContentSection)
181181
}
182182

183183
/**
184-
* Filtered/paginated index variants render genuinely different lists, but
185-
* only the bare index is indexable — same policy as the integrations and
186-
* models catalogs — so canonical always points at the unfiltered index and
187-
* the variant itself is noindexed rather than asking Google to index every
188-
* tag/page permutation.
184+
* Paginated collections contain distinct posts and use their own canonical
185+
* URLs. Tag filters remain noindexed to avoid indexing list permutations.
189186
*/
190187
export function buildIndexMetadata(
191188
section: ContentSection,
@@ -200,8 +197,8 @@ export function buildIndexMetadata(
200197
? `Sim ${section.name.toLowerCase()} posts tagged "${tag}": ${section.description}`
201198
: section.description
202199

203-
const canonical = `${SITE_URL}${section.basePath}`
204-
const isFiltered = Boolean(tag) || pageNum > 1
200+
const isFiltered = Boolean(tag)
201+
const canonical = `${SITE_URL}${section.basePath}${!isFiltered && pageNum > 1 ? `?page=${pageNum}` : ''}`
205202

206203
return withFilteredNoindex(
207204
{
@@ -363,9 +360,8 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author)
363360
*
364361
* `tag`/`page` describe which filtered/paginated variant `posts` came from,
365362
* so `url` reflects the actual page these `posts` are visible on rather than
366-
* always the bare section index - the same variant is `noindex`ed (see
367-
* `buildIndexMetadata`), but the graph still shouldn't attribute a partial
368-
* list to the unfiltered collection URL.
363+
* always the bare section index. Only tag-filtered variants are noindexed;
364+
* unfiltered pagination uses the same URL in metadata and structured data.
369365
*/
370366
export function buildCollectionPageJsonLd(
371367
section: ContentSection,

‎apps/sim/lib/landing/seo.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,8 @@ export function buildLandingMetadata({
8989
}
9090

9191
/**
92-
* Google's documented pattern for faceted/filtered navigation: keep the single
93-
* unfiltered listing indexable and `noindex` (but still `follow`) any
94-
* filtered or paginated variant, so link equity flows through without asking
95-
* Google to index every query-param permutation. Used by every catalog page
96-
* that serves distinct content per query param (integrations, models, blog,
97-
* careers, pricing) — `metadata.alternates.canonical` on all of them still
98-
* points at the bare URL regardless of `isFiltered`.
92+
* Noindexes alternate filtered views while keeping their links crawlable.
93+
* Callers decide which variants are filters; content pagination is indexable.
9994
*/
10095
export function withFilteredNoindex(metadata: Metadata, isFiltered: boolean): Metadata {
10196
return { ...metadata, ...(isFiltered && { robots: { index: false, follow: true } }) }

0 commit comments

Comments
 (0)