WrytzeWrytze Docs
Templates

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.

types.ts
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.

components/blog-card.tsx
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.

components/blog-grid.tsx
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.

components/pagination.tsx
'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.

components/category-filter.tsx
'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.

components/search-input.tsx
'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.

components/blog-list-skeleton.tsx
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.

app/blog/page.tsx
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:

app/blog/page.tsx (metadata)
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.' },
  }
}

On this page