A Complete Guide to Creating a Paginated Blog with Nuxt.js and ElmapiCMS
Creating a paginated blog with filtering and sorting capabilities using Nuxt.js and ElmapiCMS is straightforward thanks to the pagination features. This tutorial will guide you through building a Vue-based blog that supports page-based pagination, category filtering, 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 Nuxt.js Project
- Step 3: Configure Environment Variables
- Step 4: Create the Elmapi Composable
- 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
- Advantages of Nuxt.js for Paginated Sites
- Next Steps
- Complete Example
What You'll Build
By the end of this tutorial, you'll have:
- A Nuxt.js blog with page-based pagination
- Filtering by category and search functionality
- Sorting options for articles
- Pagination controls with page numbers
- Server-side rendering for better SEO
- A production-ready example you can customize for your needs
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed
- An ElmapiCMS instance running (local or remote)
- Basic knowledge of Nuxt.js and Vue.js
- The ElmapiCMS JavaScript SDK available
Step 1: Set Up the Example Project in ElmapiCMS
First, create the example project in your ElmapiCMS instance. This 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 Nuxt.js Project
Create a new Nuxt.js project and install the required dependencies:
npx nuxi@latest init pagination-blog
cd pagination-blog
npm install @elmapicms/js-sdk@^0.4.0 @nuxtjs/tailwindcss
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.
Add Tailwind CSS to your nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
})
Step 3: Configure Environment Variables
Create a .env 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
Update your nuxt.config.ts to expose these variables:
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
runtimeConfig: {
public: {
elmapiProjectId: process.env.ELMAPI_PROJECT_ID || '',
elmapiApiUrl: process.env.ELMAPI_API_URL || 'http://localhost:8000/api',
elmapiImageHost: process.env.ELMAPI_IMAGE_HOST || 'localhost:8000',
},
elmapiApiKey: process.env.ELMAPI_API_KEY || '',
},
})
Step 4: Create the Elmapi Composable
Create a composable to interact with the ElmapiCMS API. Create composables/useElmapi.ts:
import { createClient } from '@elmapicms/js-sdk';
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 const useElmapi = () => {
const config = useRuntimeConfig();
function getClient() {
const apiUrl = config.public.elmapiApiUrl;
const apiToken = config.elmapiApiKey || '';
const projectId = config.public.elmapiProjectId;
if (!projectId) {
throw new Error('ELMAPI_PROJECT_ID environment variable is required');
}
return createClient(apiUrl, apiToken, projectId);
}
const getArticlesPage = async (
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,
};
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,
};
}
};
const getArticle = async (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;
}
};
const getCategories = async (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 [];
}
};
return {
getArticlesPage,
getArticle,
getCategories,
};
};
Step 5: Create Pagination Utilities
Create a utility file for pagination helpers. Create utils/pagination.ts:
export function generatePageNumbers(currentPage: number, totalPages: number, maxVisible: number = 7): (number | string)[] {
const pages: (number | string)[] = [];
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
let start = Math.max(2, currentPage - 1);
let end = Math.min(totalPages - 1, currentPage + 1);
if (currentPage <= 3) {
end = Math.min(5, totalPages - 1);
}
if (currentPage >= totalPages - 2) {
start = Math.max(2, totalPages - 4);
}
if (start > 2) {
pages.push('...');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < totalPages - 1) {
pages.push('...');
}
if (totalPages > 1) {
pages.push(totalPages);
}
}
return pages;
}
Create a composable wrapper. Create composables/usePagination.ts:
import { generatePageNumbers } from '~/utils/pagination';
export const usePagination = () => {
return {
generatePageNumbers,
};
};
Step 6: Create the Filters Component
Create a Vue component for filtering. Create components/BlogFilters.vue:
<template>
<div class="filters">
<div class="filters-grid">
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
v-model="searchInput"
type="text"
placeholder="Search articles..."
/>
</div>
<div class="filter-group">
<label for="category">Category</label>
<select
id="category"
:value="currentFilters.category || ''"
@change="handleFilterChange('category', ($event.target as HTMLSelectElement).value)"
>
<option value="">All Categories</option>
<option v-for="cat in categories" :key="cat" :value="cat">
{{ cat }}
</option>
</select>
</div>
<div class="filter-group">
<label for="sort">Sort By</label>
<select
id="sort"
:value="currentFilters.sort || 'id:ASC'"
@change="handleFilterChange('sort', ($event.target as HTMLSelectElement).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 class="filter-group">
<label for="per_page">Items Per Page</label>
<select
id="per_page"
:value="currentFilters.per_page || 10"
@change="handleFilterChange('per_page', ($event.target as HTMLSelectElement).value)"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
categories: string[];
currentFilters: {
category?: string;
search?: string;
sort?: string;
per_page?: number;
};
}
const props = defineProps<Props>();
const route = useRoute();
const router = useRouter();
const searchInput = ref(props.currentFilters.search || '');
const debouncedSearch = ref(searchInput.value);
watch(() => props.currentFilters.search, (newValue) => {
if (newValue !== searchInput.value) {
searchInput.value = newValue || '';
debouncedSearch.value = newValue || '';
}
}, { immediate: true });
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(searchInput, (newValue) => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch.value = newValue;
}, 500);
});
watch(debouncedSearch, (newValue) => {
const currentSearch = (route.query.search as string) || '';
if (newValue === currentSearch) return;
const params = new URLSearchParams(route.query as Record<string, string>);
if (newValue) {
params.set('search', newValue);
} else {
params.delete('search');
}
params.set('page', '1');
router.push(`/blog?${params.toString()}`);
});
const handleFilterChange = (name: string, value: string) => {
const params = new URLSearchParams(route.query as Record<string, string>);
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
params.set('page', '1');
router.push(`/blog?${params.toString()}`);
};
</script>
Step 7: Create the Blog Page
Create the blog page that lists articles. Create pages/blog/index.vue:
<template>
<div class="container">
<header>
<h1>Blog Posts</h1>
<p>Browse articles with pagination, filtering, and sorting</p>
</header>
<BlogFilters
:categories="categories || []"
:current-filters="{ category, search, sort, per_page: itemsPerPage }"
/>
<div v-if="articles.length === 0" class="no-results">
<p>No articles found.</p>
<p v-if="category || search">
Try adjusting your filters or
<NuxtLink to="/blog">clear all filters</NuxtLink>.
</p>
</div>
<template v-else>
<div class="articles-list">
<article
v-for="article in articles"
:key="article.uuid"
class="article-card"
>
<h2>
<NuxtLink :to="`/blog/${article.uuid}`">
{{ article.fields.title }}
</NuxtLink>
</h2>
<div class="article-meta">
<span v-if="article.fields.category" class="category-badge">
{{ article.fields.category }}
</span>
<span v-if="article.fields.published_date">
{{ formatDate(article.fields.published_date) }}
</span>
</div>
<p v-if="article.fields.excerpt" class="excerpt">
{{ article.fields.excerpt }}
</p>
<NuxtLink :to="`/blog/${article.uuid}`" class="read-more">
Read More →
</NuxtLink>
</article>
</div>
<div class="pagination">
<NuxtLink v-if="page > 1" :to="buildPageUrl(page - 1)">
<button>Previous</button>
</NuxtLink>
<template v-if="pageNumbers.length > 0">
<template v-for="(pageNum, index) in pageNumbers" :key="index">
<span v-if="pageNum === '...'" class="pagination-ellipsis">
...
</span>
<NuxtLink v-else :to="buildPageUrl(pageNum as number)">
<button :class="{ active: pageNum === page }">
{{ pageNum }}
</button>
</NuxtLink>
</template>
</template>
<NuxtLink v-if="totalPages && page < totalPages" :to="buildPageUrl(page + 1)">
<button>Next</button>
</NuxtLink>
</div>
<div v-if="total && totalPages" class="pagination-info">
<p>
Showing page {{ page }} of {{ totalPages }} ({{ total }} total articles)
</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const { getArticlesPage, getCategories } = useElmapi();
const { generatePageNumbers } = usePagination();
const currentPage = computed(() => parseInt((route.query.page as string) || '1', 10));
const perPage = computed(() => parseInt((route.query.per_page as string) || '10', 10));
const category = computed(() => (route.query.category as string) || undefined);
const search = computed(() => (route.query.search as string) || undefined);
const sort = computed(() => (route.query.sort as string) || 'id:ASC');
const { data: articlesResult } = await useAsyncData(
() => `articles-blog-${currentPage.value}-${perPage.value}-${category.value || ''}-${search.value || ''}-${sort.value}`,
() => getArticlesPage(currentPage.value, perPage.value, {
category: category.value,
search: search.value,
sort: sort.value,
locale: 'en',
}),
{
watch: [currentPage, perPage, category, search, sort]
}
);
const { data: categories } = await useAsyncData('categories', () => getCategories('en'));
const articles = computed(() => articlesResult.value?.data || []);
const page = computed(() => articlesResult.value?.page || 1);
const itemsPerPage = computed(() => articlesResult.value?.perPage || 10);
const total = computed(() => articlesResult.value?.total);
const totalPages = computed(() => total.value ? Math.ceil(total.value / itemsPerPage.value) : undefined);
const pageNumbers = computed(() => totalPages.value ? generatePageNumbers(page.value, totalPages.value) : []);
function buildPageUrl(pageNum: number) {
const params = new URLSearchParams();
if (category.value) params.set('category', category.value);
if (search.value) params.set('search', search.value);
if (sort.value) params.set('sort', sort.value);
if (itemsPerPage.value !== 10) params.set('per_page', String(itemsPerPage.value));
params.set('page', String(pageNum));
return `/blog?${params.toString()}`;
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
</script>
Step 8: Create the Article Detail Page
Create the article detail page. Create pages/blog/[uuid].vue:
<template>
<div class="container">
<header>
<NuxtLink to="/blog" class="back-link">
← Back to Blog
</NuxtLink>
</header>
<article v-if="article" class="article-detail">
<h1>{{ article.fields.title }}</h1>
<div class="meta">
<span v-if="article.fields.category" class="category-badge">
{{ article.fields.category }}
</span>
<span v-if="article.fields.published_date">
{{ formatDate(article.fields.published_date) }}
</span>
</div>
<div class="content" v-html="article.fields.content" />
</article>
<div v-else-if="pending">Loading...</div>
<div v-else>Article not found</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const uuid = route.params.uuid as string;
const { getArticle } = useElmapi();
const { data: article, pending } = await useAsyncData(
`article-${uuid}`,
() => getArticle(uuid)
);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
</script>
How It Works
Nuxt.js provides several advantages for building paginated websites:
- Server-Side Rendering: Articles are fetched on the server, improving SEO and initial load times
- Composables: The
useElmapicomposable provides a clean API for fetching content - Reactive Data: Vue's reactivity system makes it easy to update the UI when filters or pages change
- Auto-imports: Composables and components are automatically available throughout your app
The pagination feature uses paginate and page parameters to control how many entries are returned and which page to fetch. When you switch pages or change filters:
- The page component reads query parameters from the URL
- It calls
getArticlesPage()with these parameters usinguseAsyncData - The composable uses the SDK's
getEntries()method with pagination and filter parameters - Nuxt renders the updated content on the server
- The URL updates, making the page shareable and bookmarkable
The SDK's getEntries() method supports query parameters like paginate, page, where, and sort, making it easy to fetch paginated, filtered, and sorted entries without needing to use fetch directly.
Advantages of Nuxt.js for Paginated Sites
- Built-in SSR: Better SEO for each page of results
- Auto-imports: Composables and components are automatically available
- TypeScript Support: Full type safety out of the box
- Reactive Filters: Vue's reactivity makes filter updates seamless
- Vue Ecosystem: Access to the entire Vue.js ecosystem
Next Steps
Now that you have a working paginated blog, you can:
- Add infinite scroll or load more functionality
- Implement URL-based filtering for better SEO
- Add more filter options like date ranges or tags
- Customize the styling with Tailwind CSS
- Add features like related articles or comments
- Implement client-side caching for better performance
Complete Example
A complete working example is available in the elmapi3-examples repository. The Nuxt.js example includes:
- Full Vue 3 implementation with Composition API
- Server-side rendering for better SEO
- TypeScript support throughout
- Tailwind CSS for styling
- Pagination controls with page numbers
- Filtering and sorting functionality
- Error handling and loading states
Building paginated websites with Nuxt.js and ElmapiCMS gives you the power of Vue.js with the performance of server-side rendering. The pagination API handles the complexity of managing large datasets, so you can focus on creating great user experiences.