8.0 KiB
8.0 KiB
Plan: Blog Pagination, Post Categories & Category Filters
Context / current state
- The blog is a data-only CMS:
BLOG_POSTSindata/content.tsis the single source of truth (currently 2 posts). Posts are typedBlogPostobjects withtags: string[]— there is no category concept today. - Route flow:
app/blog/page.tsx(thin server component) →components/BlogList.tsx(client component) →components/BlogPostCard.tsx.BlogListrenders every post, newest first (sortPostsNewestFirst), with a staggered reveal animation (useStaggerReveal). - Post detail:
app/blog/[slug]/page.tsxrenders 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), …) plusstyles/blog.css.ModListhas an established empty-state pattern ("Nothing published here yet."). Tag chips inBlogPostCarddefine the visual language for small mono chips. - Stack: Next.js 16 (App Router) + React 19. No
useSearchParams/Suspenseusage anywhere yet.
Goals
- Paginate the blog directory (
/bloglist view). - Give each blog post a single
category(free-formtagsstay as-is). - 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. AcategoryLabel(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
BlogPosttype: addcategory: string; // slug, e.g. "meta" — one category per post(keeptagsuntouched). - 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 fromBLOG_POSTS, no new config).categoryLabel(slug: string): string— slug → display string.postsForCategory(slug?: string): BlogPost[]— all posts when omitted, otherwise posts whosecategorymatches.paginate<T>(items: T[], page: number, perPage: number): { items: T[]; page: number; totalPages: number; total: number; startIndex: number; endIndex: number }— pure helper that also clampspageinto[1, totalPages](so?page=99lands on the last page instead of erroring).
2. components/BlogList.tsx (main change)
- Keep it a client component, but split it: default export
BlogListwraps an innerBlogListInner(which callsuseSearchParams()) in<Suspense fallback={…}>. This is required —useSearchParamson a statically rendered page makesnext buildfail 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 fromblogCategories(), styled like the tag chips inBlogPostCard(mono 11px, padded, 4px radius); active chip usesvar(--accent)/var(--accent-bg).- Each chip is a
next/linktoblogHref({ category })— switching categories dropspagefrom 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-buttonclass 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
ModListempty-state style. A query param shouldn't 404 — degrade gracefully. - Stagger animation:
useStaggerReveal's effect only re-runs when itscountchanges, so re-trigger it per page by keying the card container with${category ?? "all"}-${page}. BlogPostCardstays 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 inblogCategories(), otherwise render<BlogList category={slug} />. - This requires
BlogListto accept an optionalcategoryprop that takes precedence over the search param (the param still controlspage). 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-motiontweaks, 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/blogmetadata. - 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
data/content.ts— type, data updates, helpers. (Build should still pass; nothing consumes the new fields yet.)BlogList— category filtering + chips (no pagination yet).BlogList— pagination: controls, count line, clamping, stagger re-trigger key.styles/blog.css— chip + pagination + count styles.- Optional:
/blog/category/[category]route; optional post-page category chip. - Verification (below).
Verification
npm run build— catches theuseSearchParams/Suspenseissue and any type errors. Next 16 is strict about static-rendering requirements.npm run lint.- Manual pass:
/blogshows all posts, page 1 active,Nextdisabled whilePOSTS_PER_PAGEcovers everything.- Temporarily add a 3rd–4th fixture post → page 2 appears;
?page=2shows the rest;?page=99clamps to the last page. Remove fixtures afterwards. - Chips:
?category=metashows only meta posts; Prev/Next keep the category in the URL; switching chips dropspage. - 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).useStaggerRevealonly re-runs whencountchanges ⇒ 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 —
sortPostsNewestFirstkeeps working unchanged.