mdxui

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 posts collection
  • 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>

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

  1. Component mounts
  2. Queries Payload posts collection
  3. Applies filters (site, category, search)
  4. Paginates results
  5. 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>

On this page