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.

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.

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