Pagination SEO in Nuxt

How to implement SEO-friendly pagination in Nuxt using canonical tags, self-referencing URLs, and proper link structure.
Harlan WiltonHarlan Wilton14 mins read Published
What you'll learn
  • Each paginated page needs its own self-referencing canonical—don't point all to page 1
  • Google deprecated rel=prev/next in March 2019—use crawlable <a href> links instead
  • Infinite scroll requires hybrid approach with hidden pagination links for crawlers

Pagination splits content across multiple pages. Search engines treat each page as separate. Set canonical tags wrong and Google indexes page 1 only. Set them right and all pages rank.

Google no longer uses rel=prev/next tags (deprecated March 2019). Modern pagination relies on self-referencing canonicals and crawlable <a href> links.

Self-Referencing Canonical Tags

Each paginated page should have its own canonical URL pointing to itself.

Don't point all pages to page 1. That tells Google only page 1 matters, hiding pages 2+ from search results.

<script setup lang="ts">
const route = useRoute()
const page = Number(route.query.page) || 1

useHead({
  link: [
    {
      rel: 'canonical',
      href: `https://nuxtseo.com/blog?page=${page}`
    }
  ]
})
</script>

Self-referencing canonicals tell Google each page has unique content worth indexing.

Pagination URL Structure

Use query parameters or path segments. Both work for SEO.

URL PatternExampleSEO Impact
Query parameter/blog?page=2Good - simple, flexible
Path segment/blog/page/2Good - cleaner URLs
Hash fragment/blog#page=2Bad - Google ignores #

Query parameter approach:

Nuxt handles query parameters automatically through file-based routing:

pages/blog.vue
<script setup lang="ts">
const route = useRoute()
const page = computed(() => Number(route.query.page) || 1)

// Fetch paginated content
const { data: posts } = await useAsyncData(
  'posts',
  () => queryContent('/blog')
    .skip((page.value - 1) * 10)
    .limit(10)
    .find(),
  { watch: [page] }
)
</script>

Path segment approach:

Create nested route structure for cleaner URLs:

pages/blog/index.vue
<script setup lang="ts">
// Handles /blog
const { data: posts } = await useAsyncData(
  'posts',
  () => queryContent('/blog').limit(10).find()
)
</script>
pages/blog/page/[page].vue
<script setup lang="ts">
// Handles /blog/page/2
const route = useRoute()
const page = Number(route.params.page)

const { data: posts } = await useAsyncData(
  'posts',
  () => queryContent('/blog')
    .skip((page - 1) * 10)
    .limit(10)
    .find()
)
</script>

Never use fragment identifiers (#page=2). Google ignores everything after #.

Google needs <a href> tags to discover paginated pages. Nuxt's SSR ensures navigation is rendered in the initial HTML.

<script setup lang="ts">
const route = useRoute()
const currentPage = computed(() => Number(route.query.page) || 1)
const totalPages = 10
</script>

<template>
  <nav>
    <!-- Crawlable links with href -->
    <NuxtLink
      v-for="n in totalPages"
      :key="n"
      :to="{ query: { page: n } }"
      :class="{ active: n === currentPage }"
    >
      {{ n }}
    </NuxtLink>
  </nav>
</template>

NuxtLink generates proper <a href> tags while providing client-side navigation for users.

Pagination Component

Full example with prev/next links and numbered pages:

<script setup lang="ts">
const route = useRoute()
const currentPage = computed(() => Number(route.query.page) || 1)
const totalPages = 10

const prevPage = computed(() =>
  currentPage.value > 1 ? currentPage.value - 1 : null
)
const nextPage = computed(() =>
  currentPage.value < totalPages ? currentPage.value + 1 : null
)
</script>

<template>
  <nav aria-label="Pagination">
    <!-- Previous link -->
    <NuxtLink
      v-if="prevPage"
      :to="{ query: { page: prevPage } }"
    >
      Previous
    </NuxtLink>

    <!-- Page numbers -->
    <NuxtLink
      v-for="n in totalPages"
      :key="n"
      :to="{ query: { page: n } }"
      :aria-current="n === currentPage ? 'page' : undefined"
    >
      {{ n }}
    </NuxtLink>

    <!-- Next link -->
    <NuxtLink
      v-if="nextPage"
      :to="{ query: { page: nextPage } }"
    >
      Next
    </NuxtLink>
  </nav>
</template>

Include links from each page to following pages using <a href> tags. Googlebot follows these to discover your content.

View All Page Approach

Offer a single page with all content. Point canonicals from paginated pages to the View All page.

<script setup lang="ts">
const route = useRoute()
const showAll = route.query.show === 'all'

useHead({
  link: [
    {
      rel: 'canonical',
      href: 'https://nuxtseo.com/blog?show=all'
    }
  ]
})
</script>

<template>
  <div>
    <NuxtLink to="/blog?show=all">
      View All
    </NuxtLink>

    <!-- Paginated content -->
    <article v-for="post in paginatedPosts" :key="post.id">
      {{ post.title }}
    </article>
  </div>
</template>

Drawbacks:

  • Slow page load with 100+ items
  • Poor mobile experience
  • Images kill performance

View All pages work for small datasets (50 items). For large catalogs, use self-referencing canonicals.

Infinite Scroll vs Pagination

PatternSEO ImpactUXWhen to Use
PaginationGood - all pages indexablePredictableCatalogs, search results
Infinite scrollPoor - requires special handlingFrictionlessSocial feeds, inspiration
Load More buttonPoor - unless URL changesBalancedProduct listings

Infinite scroll SEO challenges:

Googlebot cannot scroll. Content below the fold stays hidden. Solutions:

  1. Hybrid approach - Infinite scroll for users, paginated URLs for crawlers:
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const posts = ref([])
const page = ref(Number(route.query.page) || 1)

async function loadMore() {
  page.value++
  const { data: newPosts } = await useFetch(`/api/posts?page=${page.value}`)
  posts.value.push(...newPosts.value)

  // Update URL for crawlers
  router.replace({ query: { page: page.value } })
}

const { data: initialPosts } = await useFetch(`/api/posts?page=${page.value}`)
posts.value = initialPosts.value
</script>

<template>
  <div>
    <article v-for="post in posts" :key="post.id">
      {{ post.title }}
    </article>

    <button @click="loadMore">
      Load More
    </button>

    <!-- Crawlable pagination links -->
    <nav class="sr-only">
      <NuxtLink :to="{ query: { page: page + 1 } }">
        Next Page
      </NuxtLink>
    </nav>
  </div>
</template>
  1. Component pages - Break infinite scroll into paginated URLs with unique canonicals. Google recommends paginated series alongside infinite scroll.

Load More button:

Works for UX but Googlebot can't click buttons. Use the hybrid approach above if SEO matters.

Server-Side Pagination

Nuxt Content provides built-in pagination support:

pages/blog.vue
<script setup lang="ts">
const route = useRoute()
const page = computed(() => Number(route.query.page) || 1)
const limit = 10

const { data: posts } = await useAsyncData(
  'posts',
  () => queryContent('/blog')
    .skip((page.value - 1) * limit)
    .limit(limit)
    .find(),
  { watch: [page] }
)

const { data: totalPosts } = await useAsyncData(
  'totalPosts',
  () => queryContent('/blog').count()
)

const totalPages = computed(() => Math.ceil(totalPosts.value / limit))
</script>

For API-based pagination:

server/api/posts.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const limit = 10
  const offset = (page - 1) * limit

  const posts = await db.query(
    'SELECT * FROM posts LIMIT ? OFFSET ?',
    [limit, offset]
  )

  return {
    posts,
    page,
    totalPages: Math.ceil(totalPosts / limit)
  }
})

Limit queries to avoid performance issues. Use database indexes on sort columns.

Meta Titles and Descriptions

Differentiate each paginated page for SEO:

<script setup lang="ts">
const route = useRoute()
const page = Number(route.query.page) || 1

useHead({
  title: page === 1
    ? 'Blog Posts'
    : `Blog Posts - Page ${page}`,
  meta: [
    {
      name: 'description',
      content: page === 1
        ? 'Read our latest blog posts about Nuxt SEO.'
        : `Blog posts page ${page}. Read more about Nuxt SEO.`
    }
  ],
  link: [
    {
      rel: 'canonical',
      href: `https://nuxtseo.com/blog${page > 1 ? `?page=${page}` : ''}`
    }
  ]
})
</script>

Unique titles prevent duplicate content confusion.

Noindex on Paginated Pages

Don't noindex paginated pages. This loses indexed content. Google stops crawling noindexed pages, hiding your products/articles.

Only noindex if:

  • Filter/sort variations create infinite URLs (/blog?sort=date&order=asc&filter=nuxt)
  • You have a View All page as canonical
  • Pages have no unique content

For most sites, keep paginated pages indexable.

Common Mistakes

Mistake 1: Canonicalizing all pages to page 1

<!-- ❌ Don't do this -->
<script setup lang="ts">
useHead({
  link: [
    { rel: 'canonical', href: 'https://nuxtseo.com/blog' }
  ]
})
</script>

This tells Google pages 2+ are duplicates. Use self-referencing canonicals.

Mistake 2: Using hash fragments

❌ /blog#page=2
✅ /blog?page=2
✅ /blog/page/2

Google ignores #. Your pagination won't be indexed.

Mistake 3: Client-only pagination links

<!-- ❌ Not crawlable -->
<button @click="nextPage">
Next
</button>

<!-- ✅ Crawlable -->
<NuxtLink :to="{ query: { page: page + 1 } }">
Next
</NuxtLink>

Mistake 4: Inconsistent trailing slashes

/blog?page=1
/blog/?page=2  ← Duplicate content

Pick one format. See trailing slashes guide.

Mistake 5: Blocking pagination in robots.txt

# ❌ Hides paginated content
User-agent: *
Disallow: /*?page=

Don't block pagination URLs. Google needs to crawl them.

Testing Pagination SEO

1. Check canonical tags

curl -s https://nuxtseo.com/blog?page=2 | grep canonical

Should return:

<link rel="canonical" href="https://nuxtseo.com/blog?page=2">

2. Verify crawlable links

View page source (not DevTools). Look for <a href> tags with pagination URLs. Nuxt's SSR ensures these are present in the initial HTML.

3. Google Search Console

  • URL Inspection tool
  • Check "Coverage" report for indexed pages
  • Look for paginated URLs in index

4. Site search

site:nuxtseo.com/blog inurl:page

Shows indexed paginated pages.

Sources