Blog Listing
A responsive blog listing page with cards, pagination, and category filtering.
A complete, copy-paste ready blog listing page with responsive cards, category filtering, search, pagination, and skeleton loading states. Built with Tailwind CSS for Next.js App Router.
Prefer ready-to-use components? Install @wrytze/react for pre-built BlogList, BlogCard, BlogPagination, and more — with dark mode, loading skeletons, and Next.js optimization built in. See the Next.js guide.
All components below are self-contained. Copy each file into your project, adjust the API base URL, and you are ready to go.
These components use next/image for blog thumbnails. You must add cdn.wrytze.com to your next.config.ts image remote patterns or images will not load. See the Next.js guide for the configuration.
Types
Shared type definitions used across all listing components.
export interface Blog {
id: string
title: string
slug: string
excerpt: string | null
metaTitle: string | null
metaDescription: string | null
featuredImageUrl: string | null
featuredImageAlt: string | null
wordCount: number
readingTimeMinutes: number
publishedAt: string
websiteId: string
categories: { name: string; slug: string }[]
tags: { name: string; slug: string }[]
}
export interface Pagination {
page: number
limit: number
total: number
pages: number
}
export interface BlogListResponse {
data: Blog[]
pagination: Pagination
}
export interface Category {
name: string
slug: string
}BlogCard
Displays an individual blog post as a card with a featured image, title, excerpt, metadata, and category badges.
import Image from 'next/image'
import Link from 'next/link'
import type { Blog } from '@/types'
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function BlogCard({ blog }: { blog: Blog }) {
return (
<article className="group flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-950">
<Link href={`/blog/${blog.slug}`} className="relative aspect-[16/9] overflow-hidden">
{blog.featuredImageUrl ? (
<Image
src={blog.featuredImageUrl}
alt={blog.featuredImageAlt || blog.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-100 dark:bg-gray-900">
<svg
className="h-12 w-12 text-gray-300 dark:text-gray-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z"
/>
</svg>
</div>
)}
</Link>
<div className="flex flex-1 flex-col p-5">
{blog.categories.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{blog.categories.map((category) => (
<span
key={category.slug}
className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
{category.name}
</span>
))}
</div>
)}
<Link href={`/blog/${blog.slug}`} className="group/title">
<h2 className="mb-2 text-lg font-semibold leading-snug text-gray-900 transition-colors group-hover/title:text-blue-600 dark:text-gray-100 dark:group-hover/title:text-blue-400">
{blog.title}
</h2>
</Link>
<p className="mb-4 line-clamp-2 flex-1 text-sm leading-relaxed text-gray-600 dark:text-gray-400">
{blog.excerpt}
</p>
<div className="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-gray-800">
<time
dateTime={blog.publishedAt}
className="text-sm text-gray-500 dark:text-gray-500"
>
{formatDate(blog.publishedAt)}
</time>
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{blog.readingTimeMinutes} min read
</span>
</div>
</div>
</article>
)
}BlogGrid
Renders a responsive grid of blog cards with an empty state fallback.
import type { Blog } from '@/types'
import { BlogCard } from './blog-card'
export function BlogGrid({ blogs }: { blogs: Blog[] }) {
if (blogs.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-16 text-center dark:border-gray-700">
<svg
className="mb-4 h-12 w-12 text-gray-400 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Zm3.75 11.625a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
/>
</svg>
<h3 className="mb-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
No posts found
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Try adjusting your filters or check back later for new content.
</p>
</div>
)
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{blogs.map((blog) => (
<BlogCard key={blog.id} blog={blog} />
))}
</div>
)
}Pagination
Page navigation with numbered buttons, previous/next controls, and disabled states for boundary pages.
'use client'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import type { Pagination as PaginationType } from '@/types'
function createPageUrl(
pathname: string,
searchParams: URLSearchParams,
page: number
): string {
const params = new URLSearchParams(searchParams.toString())
params.set('page', page.toString())
return `${pathname}?${params.toString()}`
}
function getVisiblePages(current: number, total: number): (number | 'ellipsis')[] {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1)
}
const pages: (number | 'ellipsis')[] = [1]
if (current > 3) {
pages.push('ellipsis')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('ellipsis')
}
pages.push(total)
return pages
}
export function Pagination({ pagination }: { pagination: PaginationType }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { page, pages: totalPages } = pagination
if (totalPages <= 1) return null
const visiblePages = getVisiblePages(page, totalPages)
return (
<nav aria-label="Pagination" className="flex items-center justify-center gap-1">
{page > 1 ? (
<Link
href={createPageUrl(pathname, searchParams, page - 1)}
className="inline-flex h-9 items-center justify-center rounded-md border border-gray-200 bg-white px-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-300 dark:hover:bg-gray-900"
>
<svg className="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Previous
</Link>
) : (
<span className="inline-flex h-9 cursor-not-allowed items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-medium text-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-600">
<svg className="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Previous
</span>
)}
<div className="hidden items-center gap-1 sm:flex">
{visiblePages.map((p, index) =>
p === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
className="inline-flex h-9 w-9 items-center justify-center text-sm text-gray-400 dark:text-gray-600"
>
...
</span>
) : p === page ? (
<span
key={p}
className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-sm font-medium text-white"
>
{p}
</span>
) : (
<Link
key={p}
href={createPageUrl(pathname, searchParams, p)}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-gray-200 bg-white text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-300 dark:hover:bg-gray-900"
>
{p}
</Link>
)
)}
</div>
<span className="inline-flex items-center px-2 text-sm text-gray-500 sm:hidden dark:text-gray-400">
Page {page} of {totalPages}
</span>
{page < totalPages ? (
<Link
href={createPageUrl(pathname, searchParams, page + 1)}
className="inline-flex h-9 items-center justify-center rounded-md border border-gray-200 bg-white px-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-300 dark:hover:bg-gray-900"
>
Next
<svg className="ml-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</Link>
) : (
<span className="inline-flex h-9 cursor-not-allowed items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-medium text-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-600">
Next
<svg className="ml-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</span>
)}
</nav>
)
}CategoryFilter
A horizontally scrollable row of category pills with an "All" option for clearing the active filter.
'use client'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import type { Category } from '@/types'
export function CategoryFilter({
categories,
activeCategory,
}: {
categories: Category[]
activeCategory?: string
}) {
const pathname = usePathname()
const searchParams = useSearchParams()
function createCategoryUrl(slug?: string): string {
const params = new URLSearchParams(searchParams.toString())
if (slug) {
params.set('category', slug)
} else {
params.delete('category')
}
params.delete('page')
return `${pathname}?${params.toString()}`
}
return (
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Link
href={createCategoryUrl()}
className={`inline-flex shrink-0 items-center rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${
!activeCategory
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
All
</Link>
{categories.map((category) => (
<Link
key={category.slug}
href={createCategoryUrl(category.slug)}
className={`inline-flex shrink-0 items-center rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${
activeCategory === category.slug
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{category.name}
</Link>
))}
</div>
)
}SearchInput
A search input field with a debounced URL update for server-side filtering.
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export function SearchInput({ defaultValue }: { defaultValue?: string }) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [value, setValue] = useState(defaultValue ?? '')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const updateUrl = useCallback(
(search: string) => {
const params = new URLSearchParams(searchParams.toString())
if (search) {
params.set('search', search)
} else {
params.delete('search')
}
params.delete('page')
router.push(`${pathname}?${params.toString()}`)
},
[pathname, router, searchParams]
)
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newValue = e.target.value
setValue(newValue)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => updateUrl(newValue), 400)
}
return (
<div className="relative">
<svg
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
<input
type="text"
placeholder="Search posts..."
value={value}
onChange={handleChange}
className="h-10 w-full rounded-lg border border-gray-200 bg-white pl-10 pr-4 text-sm text-gray-900 placeholder-gray-400 outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-blue-400 dark:focus:ring-blue-400/20"
/>
</div>
)
}BlogListSkeleton
A skeleton loading state that mirrors the layout of the blog grid for a smooth loading experience.
function CardSkeleton() {
return (
<div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800">
<div className="aspect-[16/9] animate-pulse bg-gray-200 dark:bg-gray-800" />
<div className="flex flex-1 flex-col p-5">
<div className="mb-3 flex gap-2">
<div className="h-5 w-16 animate-pulse rounded-full bg-gray-200 dark:bg-gray-800" />
<div className="h-5 w-20 animate-pulse rounded-full bg-gray-200 dark:bg-gray-800" />
</div>
<div className="mb-2 h-6 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
<div className="mb-1 h-4 w-full animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
<div className="mb-4 h-4 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-gray-900" />
<div className="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-gray-800">
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-800" />
<div className="h-6 w-20 animate-pulse rounded-md bg-gray-200 dark:bg-gray-800" />
</div>
</div>
</div>
)
}
export function BlogListSkeleton() {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
)
}Full Page
The complete blog listing page that wires all components together. This is a Next.js App Router server component that reads searchParams to handle pagination, category filtering, and search.
Replace the API key with your own. Get one from Settings → API in the Wrytze dashboard. Never expose your API key in client-side code.
import { Suspense } from 'react'
import type { BlogListResponse, Category } from '@/types'
import { BlogGrid } from '@/components/blog-grid'
import { BlogListSkeleton } from '@/components/blog-list-skeleton'
import { CategoryFilter } from '@/components/category-filter'
import { Pagination } from '@/components/pagination'
import { SearchInput } from '@/components/search-input'
const API_BASE = process.env.WRYTZE_API_URL ?? 'https://app.wrytze.com'
const API_KEY = process.env.WRYTZE_API_KEY!
async function fetchBlogs(params: {
page?: string
category?: string
search?: string
}): Promise<BlogListResponse> {
const url = new URL('/api/v1/blogs', API_BASE)
url.searchParams.set('limit', '9')
if (params.page) url.searchParams.set('page', params.page)
if (params.category) url.searchParams.set('category', params.category)
if (params.search) url.searchParams.set('search', params.search)
const response = await fetch(url.toString(), {
headers: { 'X-API-Key': API_KEY },
next: { revalidate: 60 },
})
if (!response.ok) {
throw new Error(`Failed to fetch blogs: ${response.status}`)
}
return response.json()
}
async function fetchCategories(): Promise<Category[]> {
const response = await fetch(`${API_BASE}/api/v1/categories`, {
headers: { 'X-API-Key': API_KEY },
next: { revalidate: 3600 },
})
if (!response.ok) return []
const json = await response.json()
return json.data ?? []
}
async function BlogListContent({
searchParams,
}: {
searchParams: { page?: string; category?: string; search?: string }
}) {
const [{ data: blogs, pagination }, categories] = await Promise.all([
fetchBlogs(searchParams),
fetchCategories(),
])
return (
<>
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CategoryFilter
categories={categories}
activeCategory={searchParams.category}
/>
<div className="w-full sm:w-72">
<SearchInput defaultValue={searchParams.search} />
</div>
</div>
<BlogGrid blogs={blogs} />
<div className="mt-10">
<Pagination pagination={pagination} />
</div>
</>
)
}
export default async function BlogListPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; category?: string; search?: string }>
}) {
const params = await searchParams
return (
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<div className="mb-10">
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl">
Blog
</h1>
<p className="mt-2 text-lg text-gray-600 dark:text-gray-400">
Thoughts, guides, and updates from our team.
</p>
</div>
<Suspense fallback={<BlogListSkeleton />}>
<BlogListContent searchParams={params} />
</Suspense>
</main>
)
}Metadata
For SEO, export a generateMetadata function from your page:
import type { Metadata } from 'next'
export async function generateMetadata({
searchParams,
}: {
searchParams: Promise<{ page?: string; category?: string }>
}): Promise<Metadata> {
const params = await searchParams
const title = params.category
? `${params.category} articles - Blog`
: 'Blog'
return {
title,
description: 'Thoughts, guides, and updates from our team.',
openGraph: { title, description: 'Thoughts, guides, and updates from our team.' },
}
}