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
- Prerequisites
- Step 1: Set Up the Example Project in ElmapiCMS
- Step 2: Create the Next.js Project
- Step 3: Configure Environment Variables
- Step 4: Create the API Client
- Step 5: Create Pagination Utilities
- Step 6: Create the Filters Component
- Step 7: Create the Blog Page
- Step 8: Create the Article Detail Page
- How It Works
- Next Steps
- Complete Example
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)
- Download the
pagination-example.jsonfile from the elmapi3-examples repository - In your ElmapiCMS admin panel:
- Go to Dashboard → Create Project
- Select "Import from file"
- Upload the
pagination-example.jsonfile - 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 manualfetchcalls.
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:
- The SDK sends a request with
paginate(items per page) andpage(page number) parameters - The API returns the requested page of entries along with metadata about total count
- You can combine pagination with filtering using the
whereparameter - Sorting is handled with the
sortparameter in the formatfield:ASCorfield: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.