Filters and pagination are great for users—but a minefield for SEO if URLs aren’t stable. In this tutorial, you’ll implement query-synced filters with server-side data fetching in Nuxt 3, then add canonical, prev/next, and caching headers so crawlers see clear, deduplicated pages. We’ll keep the UI snappy, the URLs shareable, and the REST parameters consistent from client to server.
The result is a browseable archive that scales to thousands of entries without duplicate-content penalties and gives you clean analytics for every slice of your catalog or blog.
Goals
- Shareable state via URL
- SSR-friendly data fetching
- Canonical + prev/next links to reduce duplicates
- Clean REST API parameters
Data fetching on server with query sync
// pages/blog.vue
<script setup lang="ts">
const route = useRoute();
const router = useRouter();
const page = computed(() => Number(route.query.page ?? 1));
const q = computed(() => String(route.query.q ?? ''));
const sort = computed(() => String(route.query.sort ?? 'new'));
const category = computed(() => String(route.query.category ?? 'all'));
const { data, pending, error } = await useAsyncData(
'posts',
() => $fetch('/api/posts', {
params: { page: page.value, q: q.value, sort: sort.value, category: category.value },
}),
{ watch: [page, q, sort, category] }
);
function updateQuery(next: Record<string, any>) {
router.push({ query: { ...route.query, ...next, page: 1 } }); // reset page on filter change
}
</script>REST API shape (server route)
// server/api/posts.get.ts
import type { H3Event } from 'h3';
export default async (event: H3Event) => {
const q = getQuery(event);
const page = Math.max(1, Number(q.page ?? 1));
const pageSize = 12;
const sort = (q.sort as string) ?? 'new';
const category = (q.category as string) ?? 'all';
// Fetch from your backend/WordPress REST
const { items, total } = await fetchFromBackend({ page, pageSize, sort, category, search: q.q });
// Optional: set Link headers for crawlers
const base = `/blog?${new URLSearchParams({ ...q, page: String(page) })}`;
setHeader(event, 'Cache-Control', 'public, max-age=60, s-maxage=300');
return { items, total, page, pageSize };
}Canonical + prev/next in head
// pages/blog.vue (continued)
definePageMeta({
title: 'Blog — Filters & Pagination',
description: 'Filter, sort, and paginate Nuxt 3 posts with SEO-friendly URLs.',
});
useHead(() => {
const url = new URL(window?.location?.href ?? 'https://example.com/blog');
const p = Number(route.query.page ?? 1);
const prev = p > 1 ? new URLSearchParams({ ...route.query, page: String(p - 1) }) : null;
const next = new URLSearchParams({ ...route.query, page: String(p + 1) });
return {
link: [
{ rel: 'canonical', href: url.toString() },
...(prev ? [{ rel: 'prev', href: `${url.pathname}?${prev.toString()}` }] : []),
{ rel: 'next', href: `${url.pathname}?${next.toString()}` },
],
};
});UI controls that sync with query
<template>
<section class="container">
<div class="filters">
<input :value="q" @input="e => updateQuery({ q: e.target.value })" placeholder="Search…" />
<select :value="category" @change="e => updateQuery({ category: e.target.value })">
<option value="all">All</option>
<option value="vue">Vue</option>
<option value="ts">TypeScript</option>
</select>
<select :value="sort" @change="e => updateQuery({ sort: e.target.value })">
<option value="new">Newest</option>
<option value="popular">Popular</option>
</select>
</div>
<ul v-if="!pending">
<li v-for="post in data?.items" :key="post.id">
<NuxtLink :to="`/blog/${post.slug}`">{{ post.title }}</NuxtLink>
</li>
</ul>
<nav class="pager">
<button :disabled="page<=1" @click="updateQuery({ page: page-1 })">Prev</button>
<span>Page {{ page }}</span>
<button @click="updateQuery({ page: page+1 })">Next</button>
</nav>
</section>
</template>Avoid duplicate content
- Keep one canonical per filtered URL.
- Don’t index empty results ( when total === 0).
- Use consistent param names across client/server/API.
FAQ
A: No—use explicit query keys so links are shareable and crawlable.
A: Keep the surface small and meaningful; combine with full-text search.