SEO-Friendly Filters & Pagination in Nuxt 3: Sync Query, SSR, Canonical Links

SEO-Friendly Filters & Pagination in Nuxt 3: Sync Query, SSR, Canonical Links

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.

© 2025 All rights reserved | made with by Obeydi using