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.
All components below are self-contained. Copy each file into your project, adjust the API base URL, and you are ready to go.
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.' },
}
}