BlogPage Component
Blog listing page with post grid, filters, and pagination
BlogPage Component
The <BlogPage> component provides a blog listing layout with post grid, category filters, search, and pagination. It auto-wires to the Payload CMS posts collection.
Interface
import type { BlogProps } from 'mdxui'
interface BlogProps {
/** Filter by site ID */
site?: string
/** Number of posts to show */
limit?: number
/** Layout variant */
layout?: 'grid' | 'list' | 'featured'
/** Show category filter */
showCategories?: boolean
/** Manual override - pass posts directly instead of auto-fetching */
posts?: any[]
}Usage
Basic Example
import { BlogPage } from '@mdxui/beacon'
<BlogPage layout="grid" limit={12} />With Manual Posts
Override auto-fetching with manual data:
const posts = [
{
title: "Getting Started with React",
slug: "getting-started-react",
excerpt: "Learn the basics of React...",
date: "2025-01-15",
author: { name: "Jane Doe" },
category: "Tutorials"
},
// ... more posts
]
<BlogPage posts={posts} layout="featured" />With Filters
<BlogPage
layout="grid"
showCategories={true}
limit={9}
/>Site-Specific Posts
Filter posts by site:
<BlogPage site="my-blog" layout="list" />Props
site (optional)
Type: string
Filter posts by site ID. Useful for multi-site setups.
<BlogPage site="tech-blog" />limit (optional)
Type: number (default: 10)
Number of posts to display per page.
<BlogPage limit={12} />layout (optional)
Type: 'grid' | 'list' | 'featured' (default: 'grid')
Layout variant for displaying posts.
// Grid layout (cards in grid)
<BlogPage layout="grid" />
// List layout (stacked list)
<BlogPage layout="list" />
// Featured layout (hero + grid)
<BlogPage layout="featured" />showCategories (optional)
Type: boolean (default: true)
Show category filter sidebar or tabs.
<BlogPage showCategories={true} />posts (optional)
Type: any[]
Manual posts data override. Use this to bypass auto-fetching.
<BlogPage posts={manualPosts} />Implementation Details
The reference implementation from @mdxui/beacon includes:
Features
- Auto-Wire to Payload - Fetches from
postscollection - Multiple Layouts - Grid, list, and featured variants
- Category Filtering - Filter posts by category
- Search - Full-text search across posts
- Pagination - Navigate through pages of posts
- Sort Options - Sort by date, title, popularity
- Responsive - Adapts to mobile, tablet, desktop
- SEO Optimized - Proper meta tags and structured data
Structure
<div className="container py-12">
{/* Header */}
<header className="mb-12">
<h1 className="text-4xl font-bold">Blog</h1>
<p className="text-xl text-muted-foreground">
Latest articles and updates
</p>
</header>
<div className="flex gap-8">
{/* Sidebar (if showCategories) */}
{showCategories && (
<aside className="w-64">
<CategoryFilter
categories={categories}
active={activeCategory}
onChange={setActiveCategory}
/>
</aside>
)}
{/* Main Content */}
<main className="flex-1">
{/* Search and Sort */}
<div className="mb-8 flex items-center gap-4">
<SearchInput
placeholder="Search posts..."
onChange={setSearchQuery}
/>
<SortSelect
options={['Latest', 'Oldest', 'Popular']}
value={sortBy}
onChange={setSortBy}
/>
</div>
{/* Posts Grid/List */}
{layout === 'grid' && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map(post => (
<BlogPostCard key={post.id} {...post} />
))}
</div>
)}
{layout === 'list' && (
<div className="space-y-6">
{posts.map(post => (
<BlogPostListItem key={post.id} {...post} />
))}
</div>
)}
{layout === 'featured' && (
<>
<FeaturedPost post={posts[0]} />
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.slice(1).map(post => (
<BlogPostCard key={post.id} {...post} />
))}
</div>
</>
)}
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onChange={setCurrentPage}
/>
</main>
</div>
</div>Layout Variants
Grid Layout
Cards in a responsive grid:
<BlogPage layout="grid" />Grid structure:
- Mobile: 1 column
- Tablet: 2 columns
- Desktop: 3 columns
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map(post => (
<BlogPostCard
key={post.id}
title={post.title}
excerpt={post.excerpt}
image={post.coverImage}
date={post.publishedAt}
author={post.author}
category={post.category}
href={`/blog/${post.slug}`}
/>
))}
</div>List Layout
Stacked list of posts:
<BlogPage layout="list" />List structure:
- Full-width items
- Image on left, content on right
- More space for excerpt text
<div className="space-y-6">
{posts.map(post => (
<BlogPostListItem
key={post.id}
title={post.title}
excerpt={post.excerpt}
image={post.coverImage}
date={post.publishedAt}
author={post.author}
readTime={post.readTime}
href={`/blog/${post.slug}`}
/>
))}
</div>Featured Layout
Hero post + grid:
<BlogPage layout="featured" />Featured structure:
- First post as large hero card
- Remaining posts in grid below
<>
<FeaturedPost
title={posts[0].title}
excerpt={posts[0].excerpt}
image={posts[0].coverImage}
author={posts[0].author}
date={posts[0].publishedAt}
href={`/blog/${posts[0].slug}`}
className="mb-12"
/>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.slice(1).map(post => (
<BlogPostCard key={post.id} {...post} />
))}
</div>
</>Auto-Wiring
The BlogPage component auto-wires to Payload CMS:
How It Works
- Component mounts
- Queries Payload
postscollection - Applies filters (site, category, search)
- Paginates results
- Renders post cards
Payload Collection Schema
const Posts = {
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'excerpt', type: 'textarea' },
{ name: 'content', type: 'richText', required: true },
{ name: 'coverImage', type: 'upload', relationTo: 'media' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
{ name: 'category', type: 'relationship', relationTo: 'categories' },
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true },
{ name: 'publishedAt', type: 'date' },
{ name: 'featured', type: 'checkbox' },
]
}Example Query
const { docs: posts, totalDocs } = await payload.find({
collection: 'posts',
where: {
site: { equals: siteId },
...(category && { category: { equals: category } })
},
sort: '-publishedAt',
limit,
page: currentPage,
})Common Use Cases
Company Blog
<BlogPage
layout="featured"
limit={10}
showCategories={true}
/>Categories:
- Product Updates
- Engineering
- Company News
- Tutorials
Personal Blog
<BlogPage
layout="list"
limit={5}
showCategories={false}
/>Simple, chronological list
Multi-Author Blog
const BlogPageWithAuthors = () => {
const [selectedAuthor, setSelectedAuthor] = useState(null)
return (
<BlogPage layout="grid">
<AuthorFilter
authors={authors}
selected={selectedAuthor}
onChange={setSelectedAuthor}
/>
</BlogPage>
)
}Technical Blog
<BlogPage
layout="grid"
showCategories={true}
limit={12}
/>Categories:
- Tutorials
- Deep Dives
- Announcements
- Case Studies
Category Filter
Sidebar category filter:
const CategoryFilter = ({ categories, active, onChange }) => {
return (
<div className="space-y-2">
<h3 className="font-semibold mb-4">Categories</h3>
<button
onClick={() => onChange(null)}
className={cn(
"w-full text-left px-4 py-2 rounded",
active === null && "bg-primary text-primary-foreground"
)}
>
All Posts
</button>
{categories.map(category => (
<button
key={category.id}
onClick={() => onChange(category.id)}
className={cn(
"w-full text-left px-4 py-2 rounded",
active === category.id && "bg-primary text-primary-foreground"
)}
>
{category.name}
<span className="ml-2 text-muted-foreground">
({category.count})
</span>
</button>
))}
</div>
)
}Search and Sort
Search and sort controls:
const SearchAndSort = ({ onSearch, onSort, sortBy }) => {
return (
<div className="flex items-center gap-4 mb-8">
<div className="flex-1">
<Input
type="search"
placeholder="Search posts..."
onChange={(e) => onSearch(e.target.value)}
className="w-full"
/>
</div>
<Select value={sortBy} onValueChange={onSort}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="latest">Latest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
<SelectItem value="popular">Most Popular</SelectItem>
<SelectItem value="title">Alphabetical</SelectItem>
</SelectContent>
</Select>
</div>
)
}Pagination
Page navigation:
const Pagination = ({ currentPage, totalPages, onChange }) => {
return (
<div className="mt-12 flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => onChange(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</Button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
<Button
key={page}
variant={page === currentPage ? "default" : "outline"}
onClick={() => onChange(page)}
>
{page}
</Button>
))}
<Button
variant="outline"
onClick={() => onChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
)
}Post Card Component
Individual post card:
const BlogPostCard = ({
title,
excerpt,
image,
date,
author,
category,
href
}) => {
return (
<article className="group flex flex-col overflow-hidden rounded-lg border bg-card">
{image && (
<Link href={href}>
<img
src={image}
alt={title}
className="aspect-video object-cover transition group-hover:scale-105"
/>
</Link>
)}
<div className="flex-1 p-6">
{category && (
<Badge variant="secondary" className="mb-3">
{category.name}
</Badge>
)}
<h3 className="mb-2 text-xl font-semibold">
<Link href={href} className="hover:underline">
{title}
</Link>
</h3>
<p className="mb-4 text-muted-foreground line-clamp-3">
{excerpt}
</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={author.avatar} />
<AvatarFallback>{author.name[0]}</AvatarFallback>
</Avatar>
{author.name}
</div>
<time dateTime={date}>
{new Date(date).toLocaleDateString()}
</time>
</div>
</div>
</article>
)
}Accessibility
The reference implementation includes:
- Semantic HTML - Uses
<article>,<time>,<nav> - ARIA Labels - Proper labeling for filters and pagination
- Keyboard Navigation - Full keyboard support
- Focus Management - Visible focus indicators
- Screen Reader Support - Descriptive labels
- Skip Links - Skip to search, skip to posts
<main aria-label="Blog posts">
<div role="search" aria-label="Search posts">
<input
type="search"
aria-label="Search blog posts"
placeholder="Search..."
/>
</div>
<div role="feed" aria-label="Blog posts list">
{posts.map(post => (
<article
key={post.id}
aria-labelledby={`post-${post.id}-title`}
>
<h3 id={`post-${post.id}-title`}>{post.title}</h3>
</article>
))}
</div>
<nav aria-label="Pagination">
{/* Pagination controls */}
</nav>
</main>TypeScript
Full type definitions:
import type { ReactNode } from 'react'
import type { z } from 'zod'
import type { Component } from 'mdxui'
export const BlogPropsSchema = z.object({
site: z.string().optional(),
limit: z.number().default(10),
layout: z.enum(['grid', 'list', 'featured']).default('grid'),
showCategories: z.boolean().default(true),
posts: z.array(z.any()).optional(),
})
export type BlogProps = z.infer<typeof BlogPropsSchema>
export type BlogPage = Component<BlogProps>Related Components
- BlogPost Component - Individual post layout
- Page Component - Generic page layout
- Card Component - Base card component
- Pagination Component - Pagination controls