Blog Card Grid Pattern
Live PreviewOpen in Storybook ↗
A responsive card grid is the standard layout for blog index pages, article listings, and content hubs. This pattern uses prokodo UI's PostTeaser, Grid, and Pagination components to build a fully server-rendered listing page with optional client-side pagination.
When to use this pattern
- Blog index pages, article archives, and news feeds
- Content marketing hubs served from a headless CMS
- Any listing page where visual hierarchy and LCP performance matter
app/blog/BlogPagination.tsx
"use client"
import { useRouter } from "next/navigation"
import { Pagination } from "@prokodo/ui/pagination/client"
import "@prokodo/ui/pagination.css"
export function BlogPagination({
page,
totalPages,
}: {
page: number
totalPages: number
}) {
const router = useRouter()
return (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={p => router.push(`?page=${p}`)}
style={{ marginTop: "3rem" }}
/>
)
}
Static listing (RSC, recommended)
app/blog/page.tsx
import { Grid } from "@prokodo/ui/grid"
import { PostTeaser } from "@prokodo/ui/post-teaser"
import { BlogPagination } from "./BlogPagination"
import "@prokodo/ui/grid.css"
import "@prokodo/ui/post-teaser.css"
interface SearchParams {
page?: string
}
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const page = Number(sp.page ?? 1)
const limit = 12
const { posts, total } = await fetchPosts({ page, limit })
return (
<main style={{ padding: "2rem", maxWidth: "1280px", margin: "0 auto" }}>
<h1>Blog</h1>
<Grid columns={{ xs: 1, sm: 2, lg: 3 }} gap="lg">
{posts.map(post => (
<PostTeaser
key={post.slug}
title={{ content: post.title }}
content={post.excerpt}
image={{ src: post.coverImage, alt: post.title }}
redirect={{ href: `/blog/${post.slug}`, label: "Read more" }}
date={post.publishedAt}
locale="en"
category={post.category}
/>
))}
</Grid>
<BlogPagination page={page} totalPages={Math.ceil(total / limit)} />
</main>
)
}
CMS integration (Strapi example)
lib/posts.ts
interface PostsResult {
posts: PostSummary[]
total: number
}
export async function fetchPosts({
page = 1,
limit = 12,
}: {
page?: number
limit?: number
}): Promise<PostsResult> {
const qs = new URLSearchParams({
"pagination[page]": String(page),
"pagination[pageSize]": String(limit),
populate: "coverImage,author,category",
sort: "publishedAt:desc",
})
const res = await fetch(
`${process.env.STRAPI_URL}/api/articles?${qs}`,
{ next: { revalidate: 60 } }, // ISR — revalidate every 60 s
)
if (!res.ok) throw new Error("Failed to fetch posts")
const json = await res.json()
return {
posts: json.data.map(mapPost),
total: json.meta.pagination.total,
}
}
SEO & metadata
app/blog/page.tsx (metadata export)
export const metadata = {
title: "Blog — prokodo UI",
description: "Latest articles on React, Next.js, and component design.",
alternates: {
canonical: "https://example.com/blog",
},
}
Performance tips
| Technique | Implementation |
|---|---|
| Prioritise LCP image | <PostTeaser imagePriority /> on first 3 cards |
| Responsive images | Set sizes prop on PostTeaser image |
| ISR | fetch(…, { next: { revalidate: 60 } }) |
| Prefetch on hover | Use <Link prefetch> on card wrappers |
Related components
PostTeaser— card with image, title, excerpt, metaGrid— responsive CSS grid wrapperPagination— URL-driven page navigationCard— generic card container for non-article content
Further reading
- Next.js Image component
- ISR with On-Demand Revalidation
- prokodo Next.js agency — we build and maintain content-heavy Next.js sites