Building a Paginated Blog with Next.js and ElmapiCMS Headless CMS

Learn how to build a paginated blog using Next.js and ElmapiCMS, a headless CMS. This tutorial walks you through creating pagination controls, filtering, and sorting functionality.

R
Raşit Apalak
·
·
11 min read

A Step-by-Step Guide to Creating a Paginated Blog with Next.js and ElmapiCMS

Building a paginated blog with filtering and sorting capabilities can be complex, but with ElmapiCMS pagination features and Next.js, you can create a seamless blog experience. This tutorial will show you how to build a blog that supports page-based pagination, filtering by category, search functionality, and sorting options.


Table of Contents


What You'll Build

By the end of this tutorial, you'll have:

  • A Next.js blog with page-based pagination
  • Filtering by category and search functionality
  • Sorting options for articles
  • Pagination controls with page numbers
  • Integration with ElmapiCMS pagination API
  • A working example you can use as a foundation for your own projects

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ installed
  • An ElmapiCMS instance running (local or remote)
  • Basic knowledge of Next.js and React
  • The ElmapiCMS JavaScript SDK installed

Step 1: Set Up the Example Project in ElmapiCMS

First, you need to create the example project in your ElmapiCMS instance. This project includes sample articles with categories and metadata.

Option 1: Import JSON Template (Recommended)

  1. Download the pagination-example.json file from the elmapi3-examples repository
  2. In your ElmapiCMS admin panel:
    • Go to DashboardCreate Project
    • Select "Import from file"
    • Upload the pagination-example.json file
    • Click Create Project

This creates:

  • A project with articles collection
  • Fields for title, slug, content, excerpt, published date, category, and views
  • Sample articles with various categories and content

Tip: You can generate more content using AI - see our tutorial on generating content with AI.

Option 2: Use Seeder (Legacy)

If you prefer using seeders, you can still run:

php artisan db:seed --class=PaginationExampleSeeder

Step 2: Create the Next.js Project

Create a new Next.js project and install the required dependencies:

npx create-next-app@latest pagination-blog
cd pagination-blog
npm install @elmapicms/js-sdk@^0.4.0

Note: This tutorial uses SDK v0.4.0 or later, which includes support for pagination parameters in the getEntries() method. This allows us to use the SDK's native methods instead of manual fetch calls.


Step 3: Configure Environment Variables

Create a .env.local file in your project root:

ELMAPI_PROJECT_ID=your-project-uuid
ELMAPI_API_URL=https://your-instance.elmapi.com/api
ELMAPI_IMAGE_HOST=your-instance.elmapi.com

Replace the values with your actual ElmapiCMS configuration. You'll get the Project ID from the API Access page in your ElmapiCMS admin panel after creating the project.


Step 4: Create the API Client

Create a utility file to interact with the ElmapiCMS API. Create lib/api.ts:

import { createClient } from '@elmapicms/js-sdk';

// Lazy initialization of SDK client
function getClient() {
  const apiUrl = process.env.ELMAPI_API_URL || 'http://localhost:8000/api';
  const apiToken = process.env.ELMAPI_API_KEY || '';
  const projectId = process.env.ELMAPI_PROJECT_ID || '';

  if (!projectId) {
    throw new Error('ELMAPI_PROJECT_ID environment variable is required');
  }

  return createClient(apiUrl, apiToken, projectId);
}

export interface Article {
  uuid: string;
  locale: string;
  published_at: string | null;
  fields: {
    title: string;
    slug: string;
    content: string;
    excerpt: string;
    published_date: string | null;
    category?: string;
    views?: string;
  };
}

export interface ArticleFilters {
  category?: string;
  search?: string;
  sort?: string;
  locale?: string;
}

export async function getArticlesPage(
  page: number = 1,
  perPage: number = 10,
  filters: ArticleFilters = {}
): Promise<{ data: Article[]; page: number; perPage: number; total?: number }> {
  try {
    const client = getClient();
    const { category, search, sort, locale = 'en' } = filters;

    const params: any = {
      paginate: perPage,
      page,
      locale,
    };

    // Build where clause object
    const where: any = {};
    
    if (category) {
      where.category = category;
    }

    if (search) {
      where.title = {
        like: `%${search}%`,
      };
    }

    if (Object.keys(where).length > 0) {
      params.where = where;
    }

    if (sort) {
      const normalizedSort = sort.split(':').map((part, index) => {
        if (index === 1) {
          return part.toUpperCase();
        }
        return part;
      }).join(':');
      params.sort = normalizedSort;
    }

    const response = await client.getEntries('articles', params);

    const articles = Array.isArray(response)
      ? (response as Article[])
      : (response as any)?.data || [];

    let total: number | undefined = undefined;
    if (!Array.isArray(response)) {
      const resp = response as any;
      total = resp?.total ?? resp?.meta?.total ?? resp?.pagination?.total ?? resp?.count;
    }

    return {
      data: articles,
      page,
      perPage,
      total,
    };
  } catch (error) {
    console.error('Error fetching articles:', error);
    return {
      data: [],
      page: 1,
      perPage: 10,
    };
  }
}

export async function getArticle(uuid: string, locale?: string): Promise<Article | null> {
  try {
    const client = getClient();
    const params = locale ? { locale } : undefined;
    const entry = await client.getEntry('articles', uuid, params) as any;
    const article = (entry?.data || entry) as Article;
    return article || null;
  } catch (error) {
    console.error('Error fetching article:', error);
    return null;
  }
}

export async function getCategories(locale: string = 'en'): Promise<string[]> {
  try {
    const client = getClient();
    const entries = await client.getEntries('articles', {
      locale,
    });

    const articles = Array.isArray(entries) ? (entries as Article[]) : [];
    const categories = new Set<string>();

    articles.forEach((article) => {
      if (article.fields.category) {
        categories.add(article.fields.category);
      }
    });

    return Array.from(categories).sort();
  } catch (error) {
    console.error('Error fetching categories:', error);
    return [];
  }
}

Step 5: Create Pagination Utilities

Create a utility file for pagination helpers. Create lib/pagination.ts:

export function generatePageNumbers(currentPage: number, totalPages: number, maxVisible: number = 7): (number | string)[] {
  const pages: (number | string)[] = [];

  if (totalPages <= maxVisible) {
    // Show all pages if total is less than max visible
    for (let i = 1; i <= totalPages; i++) {
      pages.push(i);
    }
  } else {
    // Always show first page
    pages.push(1);

    let start = Math.max(2, currentPage - 1);
    let end = Math.min(totalPages - 1, currentPage + 1);

    // Adjust if we're near the start
    if (currentPage <= 3) {
      end = Math.min(5, totalPages - 1);
    }

    // Adjust if we're near the end
    if (currentPage >= totalPages - 2) {
      start = Math.max(2, totalPages - 4);
    }

    // Add ellipsis after first page if needed
    if (start > 2) {
      pages.push('...');
    }

    // Add page numbers around current page
    for (let i = start; i <= end; i++) {
      pages.push(i);
    }

    // Add ellipsis before last page if needed
    if (end < totalPages - 1) {
      pages.push('...');
    }

    // Always show last page
    if (totalPages > 1) {
      pages.push(totalPages);
    }
  }

  return pages;
}

Step 6: Create the Filters Component

Create a client component for filtering. Create app/blog/filters.tsx:

'use client';

import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';

interface FiltersProps {
  categories: string[];
  currentFilters: {
    category?: string;
    search?: string;
    sort?: string;
    per_page?: number;
  };
}

export default function Filters({ categories, currentFilters }: FiltersProps) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [search, setSearch] = useState(currentFilters.search || '');

  const updateFilters = (updates: Record<string, string | undefined>) => {
    const params = new URLSearchParams(searchParams.toString());
    
    Object.entries(updates).forEach(([key, value]) => {
      if (value) {
        params.set(key, value);
      } else {
        params.delete(key);
      }
    });

    // Reset to page 1 when filters change
    params.set('page', '1');
    router.push(`/blog?${params.toString()}`);
  };

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    updateFilters({ search: search || undefined });
  };

  return (
    <div className="filters">
      <form onSubmit={handleSearch} className="search-form">
        <input
          type="text"
          placeholder="Search articles..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <button type="submit">Search</button>
      </form>

      <div className="filter-group">
        <label>Category:</label>
        <select
          value={currentFilters.category || ''}
          onChange={(e) => updateFilters({ category: e.target.value || undefined })}
        >
          <option value="">All Categories</option>
          {categories.map((cat) => (
            <option key={cat} value={cat}>
              {cat}
            </option>
          ))}
        </select>
      </div>

      <div className="filter-group">
        <label>Sort:</label>
        <select
          value={currentFilters.sort || 'id:ASC'}
          onChange={(e) => updateFilters({ sort: e.target.value })}
        >
          <option value="id:ASC">Newest First</option>
          <option value="id:DESC">Oldest First</option>
          <option value="title:ASC">Title A-Z</option>
          <option value="title:DESC">Title Z-A</option>
        </select>
      </div>

      <div className="filter-group">
        <label>Per Page:</label>
        <select
          value={currentFilters.per_page || 10}
          onChange={(e) => updateFilters({ per_page: e.target.value })}
        >
          <option value="5">5</option>
          <option value="10">10</option>
          <option value="20">20</option>
          <option value="50">50</option>
        </select>
      </div>
    </div>
  );
}

Step 7: Create the Blog Page

Create the blog page that lists articles with pagination. Create app/blog/page.tsx:

import { getArticlesPage, getCategories } from '@/lib/api';
import Link from 'next/link';
import Filters from './filters';
import { generatePageNumbers } from '@/lib/pagination';

interface BlogPageProps {
  searchParams: Promise<{
    page?: string;
    category?: string;
    search?: string;
    sort?: string;
    per_page?: string;
  }>;
}

export default async function BlogPage({ searchParams }: BlogPageProps) {
  const params = await searchParams;
  const currentPage = parseInt(params.page || '1', 10);
  const perPage = parseInt(params.per_page || '10', 10);
  const category = params.category || undefined;
  const search = params.search || undefined;
  const sort = params.sort || 'id:ASC';

  const [articlesResult, categories] = await Promise.all([
    getArticlesPage(currentPage, perPage, {
      category,
      search,
      sort,
      locale: 'en',
    }),
    getCategories('en'),
  ]);

  const { data: articles, page, perPage: itemsPerPage, total } = articlesResult;
  const totalPages = total ? Math.ceil(total / itemsPerPage) : undefined;
  const pageNumbers = totalPages ? generatePageNumbers(page, totalPages) : [];

  const buildPageUrl = (pageNum: number) => {
    return `/blog?${new URLSearchParams({
      ...(category && { category }),
      ...(search && { search }),
      ...(sort && { sort }),
      ...(itemsPerPage !== 10 && { per_page: String(itemsPerPage) }),
      page: String(pageNum),
    }).toString()}`;
  };

  return (
    <div className="container">
      <header>
        <h1>Blog Posts</h1>
        <p>Browse articles with pagination, filtering, and sorting</p>
      </header>

      <Filters categories={categories} currentFilters={{ category, search, sort, per_page: itemsPerPage }} />

      {articles.length === 0 ? (
        <div className="no-results">
          <p>No articles found.</p>
          {(category || search) && (
            <p>
              Try adjusting your filters or{' '}
              <Link href="/blog">clear all filters</Link>.
            </p>
          )}
        </div>
      ) : (
        <>
          <div className="articles-list">
            {articles.map((article) => (
              <article key={article.uuid} className="article-card">
                <h2>
                  <Link href={`/blog/${article.uuid}`}>{article.fields.title}</Link>
                </h2>
                <div className="article-meta">
                  {article.fields.category && (
                    <span className="category-badge">{article.fields.category}</span>
                  )}
                  {article.fields.published_date && (
                    <span>
                      {new Date(article.fields.published_date).toLocaleDateString('en-US', {
                        year: 'numeric',
                        month: 'long',
                        day: 'numeric',
                      })}
                    </span>
                  )}
                </div>
                {article.fields.excerpt && (
                  <p className="excerpt">{article.fields.excerpt}</p>
                )}
                <Link href={`/blog/${article.uuid}`} className="read-more">
                  Read More →
                </Link>
              </article>
            ))}
          </div>

          <div className="pagination">
            {page > 1 && (
              <Link href={buildPageUrl(page - 1)}>
                <button>Previous</button>
              </Link>
            )}

            {pageNumbers.length > 0 && (
              <>
                {pageNumbers.map((pageNum, index) => {
                  if (pageNum === '...') {
                    return (
                      <span key={`ellipsis-${index}`} className="pagination-ellipsis">
                        ...
                      </span>
                    );
                  }
                  const pageNumValue = pageNum as number;
                  return (
                    <Link key={pageNumValue} href={buildPageUrl(pageNumValue)}>
                      <button className={pageNumValue === page ? 'active' : ''}>
                        {pageNumValue}
                      </button>
                    </Link>
                  );
                })}
              </>
            )}

            {totalPages && page < totalPages && (
              <Link href={buildPageUrl(page + 1)}>
                <button>Next</button>
              </Link>
            )}
          </div>

          {total && totalPages && (
            <div className="pagination-info">
              <p>
                Showing page {page} of {totalPages} ({total} total articles)
              </p>
            </div>
          )}
        </>
      )}
    </div>
  );
}

Step 8: Create the Article Detail Page

Create the article detail page. Create app/blog/[uuid]/page.tsx:

import { getArticle } from '@/lib/api';
import Link from 'next/link';
import { notFound } from 'next/navigation';

interface ArticlePageProps {
  params: {
    uuid: string;
  };
}

export default async function ArticlePage({ params }: ArticlePageProps) {
  const article = await getArticle(params.uuid);

  if (!article) {
    notFound();
  }

  return (
    <div className="container">
      <header>
        <Link href="/blog" className="back-link">
          ← Back to Blog
        </Link>
      </header>

      <article className="article-detail">
        <h1>{article.fields.title}</h1>
        <div className="meta">
          {article.fields.category && (
            <span className="category-badge">{article.fields.category}</span>
          )}
          {article.fields.published_date && (
            <span>
              {new Date(article.fields.published_date).toLocaleDateString('en-US', {
                year: 'numeric',
                month: 'long',
                day: 'numeric',
              })}
            </span>
          )}
        </div>
        <div
          className="content"
          dangerouslySetInnerHTML={{ __html: article.fields.content }}
        />
      </article>
    </div>
  );
}

How It Works

The pagination feature in ElmapiCMS uses paginate and page parameters to control how many entries are returned and which page to fetch. When you call getEntries() with pagination parameters:

  1. The SDK sends a request with paginate (items per page) and page (page number) parameters
  2. The API returns the requested page of entries along with metadata about total count
  3. You can combine pagination with filtering using the where parameter
  4. Sorting is handled with the sort parameter in the format field:ASC or field:DESC

The blog page component:

  • Reads query parameters from the URL to determine current page, filters, and sort options
  • Calls getArticlesPage() with these parameters
  • Renders the articles and pagination controls
  • Updates the URL when filters or page changes, triggering a new server-side fetch

This creates a seamless experience where users can navigate through pages, filter by category, search, and sort articles, all with server-side rendering for better performance and SEO.


Next Steps

Now that you have a working paginated blog, you can:

  • Customize the styling to match your brand
  • Add infinite scroll or load more functionality
  • Implement URL-based filtering for better SEO
  • Add more filter options like date ranges or tags
  • Extend the functionality with related articles or comments
  • Add client-side caching for better performance

Complete Example

A complete working example is available in the elmapi3-examples repository. You can clone it, follow the setup instructions, and start building your own paginated website.

The example includes:

  • Full Next.js implementation with Server Components
  • Page-based pagination with controls
  • Filtering and sorting functionality
  • Search capabilities
  • Error handling and loading states

Building paginated websites doesn't have to be complicated. With ElmapiCMS pagination features and Next.js, you can create a professional blog with advanced filtering and sorting in just a few steps. The pagination API handles the complexity of managing large datasets, so you can focus on creating great content.

Share this post:

Ready to build your content API?

Try ElmapiCMS free with our live demo, or get the full source code for $149.

Comparing options first? Read our best headless CMS comparison.

Related Posts