# 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(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 ``. **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 ``; 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 ``. - 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=`, 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.