Files
hwilliams-dev/blog-pagination-and-categories-plan.md
2026-11-01 05:15:25 +00:00

8.0 KiB
Raw Permalink Blame History

Plan: Blog Pagination, Post Categories & Category Filters

Context / current state

  • The blog is a data-only CMS: BLOG_POSTS in data/content.ts is the single source of truth (currently 2 posts). Posts are typed BlogPost objects with tags: string[] — there is no category concept today.
  • Route flow: app/blog/page.tsx (thin server component) → components/BlogList.tsx (client component) → components/BlogPostCard.tsx. BlogList renders every post, newest first (sortPostsNewestFirst), with a staggered reveal animation (useStaggerReveal).
  • Post detail: app/blog/[slug]/page.tsx renders markdown via the shared remark pipeline, with "Back To Top" / "All Posts" links.
  • Styling conventions: inline styles using CSS variables (var(--mono), var(--accent), var(--accent-bg), …) plus styles/blog.css. ModList has an established empty-state pattern ("Nothing published here yet."). Tag chips in BlogPostCard define the visual language for small mono chips.
  • Stack: Next.js 16 (App Router) + React 19. No useSearchParams / Suspense usage anywhere yet.

Goals

  1. Paginate the blog directory (/blog list view).
  2. Give each blog post a single category (free-form tags stay as-is).
  3. Filter the blog directory by category: chip row + URL param, with a stable URL shape.

URL design

URL Meaning
/blog All posts, page 1
/blog?page=2 Page 2 of all posts
/blog?category=meta All meta posts, page 1
/blog?category=meta&page=2 Page 2 of meta posts
  • Category values are stored as slugs (kebab-case, lowercase) in the data, e.g. meta, development. A categoryLabel(slug) helper derives the display string ("meta", "Development").
  • All URL building goes through one helper (blogHref({ page?, category? })) so the shape has a single source of truth.

Changes by file

1. data/content.ts

  • Extend the BlogPost type: add category: string; // slug, e.g. "meta" — one category per post (keep tags untouched).
  • Update existing posts: "Adding a blog tab to a portfolio site" → category: "development"; "Hello, world. This is the new blog." → category: "meta".
  • New exports:
    • POSTS_PER_PAGE = 3 — small enough that pagination is visible as soon as a few posts exist.
    • blogCategories(): string[] — deduplicated, alphabetically sorted categories in use (derived from BLOG_POSTS, no new config).
    • categoryLabel(slug: string): string — slug → display string.
    • postsForCategory(slug?: string): BlogPost[] — all posts when omitted, otherwise posts whose category matches.
    • paginate<T>(items: T[], page: number, perPage: number): { items: T[]; page: number; totalPages: number; total: number; startIndex: number; endIndex: number } — pure helper that also clamps page into [1, totalPages] (so ?page=99 lands on the last page instead of erroring).

2. components/BlogList.tsx (main change)

  • Keep it a client component, but split it: default export BlogList wraps an inner BlogListInner (which calls useSearchParams()) in <Suspense fallback={…}>. This is required — useSearchParams on a statically rendered page makes next build fail without a Suspense boundary.
  • Read params: page = Number(searchParams.get("page") ?? 1), category = searchParams.get("category") ?? undefined.
  • Data pipeline: postsForCategory(category) → sortPostsNewestFirst → paginate(…, POSTS_PER_PAGE).
  • Category filter row (chips, above the post stack):
    • all + one chip per entry from blogCategories(), styled like the tag chips in BlogPostCard (mono 11px, padded, 4px radius); active chip uses var(--accent) / var(--accent-bg).
    • Each chip is a next/link to blogHref({ category }) — switching categories drops page from the URL.
  • Pagination controls (below the stack):
    • ‹ Prev / Next › plus numbered page buttons (fine at this scale; revisit if we ever exceed ~9 pages).
    • Reuse the existing expand-on-hover-button class so links match the site's look; render disabled states as non-links on first/last page (no dead hrefs).
    • Mono/muted count line: showing 1–3 of 5.
  • Empty state: when the filtered list is empty (e.g. unknown category slug) render "No posts in this category yet." in the ModList empty-state style. A query param shouldn't 404 — degrade gracefully.
  • Stagger animation: useStaggerReveal's effect only re-runs when its count changes, so re-trigger it per page by keying the card container with ${category ?? "all"}-${page}.
  • BlogPostCard stays unchanged (out of scope).

3. app/blog/page.tsx

  • No change: still renders <BlogList />; filtering/pagination are self-managed via search params.

4. (Optional stretch) app/blog/category/[category]/page.tsx

  • Thin server component: notFound() if the slug is not in blogCategories(), otherwise render <BlogList category={slug} />.
  • This requires BlogList to accept an optional category prop that takes precedence over the search param (the param still controls page). Chip links on that page use the /blog?category=… form so navigation stays consistent.
  • Marked optional: the chips + ?category= param already deliver the category feature; this route is only for cleaner shareable URLs/SEO. If time is short, skip it.

5. styles/blog.css

  • Add, matching the file's existing conventions:
    • .blog-category-filters — flex row, wrap, gap, margin below the heading.
    • .blog-category-chip (+ .is-active) — mono chip look, accent when active.
    • .blog-pagination — flex row, space-between, mono ~12px, top margin.
    • .blog-count — muted mono count line.
    • Narrow-screen + prefers-reduced-motion tweaks, mirroring the existing media queries.

6. (Optional, low priority) app/blog/[slug]/page.tsx

  • Show the post's category as a small chip above the content, linking to /blog?category=<slug>, mirroring the tag-chip look. Nice-to-have for discoverability; doesn't block the core feature.

Out of scope

  • Tag-based or multi-select filtering (tags remain display-only metadata on cards).
  • Custom metadata (generateMetadata) per category — /blog?category=… reuses the existing /blog metadata.
  • Infinite scroll — explicit pagination was chosen for keyboard access and stable URLs.
  • Editing tooling for posts — the data array remains the CMS, per the site's existing architecture.

Implementation order

  1. data/content.ts — type, data updates, helpers. (Build should still pass; nothing consumes the new fields yet.)
  2. BlogList — category filtering + chips (no pagination yet).
  3. BlogList — pagination: controls, count line, clamping, stagger re-trigger key.
  4. styles/blog.css — chip + pagination + count styles.
  5. Optional: /blog/category/[category] route; optional post-page category chip.
  6. Verification (below).

Verification

  • npm run build — catches the useSearchParams/Suspense issue and any type errors. Next 16 is strict about static-rendering requirements.
  • npm run lint.
  • Manual pass:
    • /blog shows all posts, page 1 active, Next disabled while POSTS_PER_PAGE covers everything.
    • Temporarily add a 3rd–4th fixture post → page 2 appears; ?page=2 shows the rest; ?page=99 clamps to the last page. Remove fixtures afterwards.
    • Chips: ?category=meta shows only meta posts; Prev/Next keep the category in the URL; switching chips drops page.
    • Unknown category slug → empty state, no crash.
    • Post pages: "Back To Top" / "All Posts" still work; (if implemented) category chip links correctly.

Risks / notes

  • useSearchParams + static page ⇒ Suspense boundary is mandatory at build time (handled in step 2 of the component change).
  • useStaggerReveal only re-runs when count changes ⇒ use the container key to re-trigger per page.
  • Keep the design language: CSS variables, inline styles, existing button classes, no new dependencies.
  • Dates are ISO and compare lexicographically — sortPostsNewestFirst keeps working unchanged.