Pagination SEO for Vue Applications

How to implement SEO-friendly pagination in Vue using canonical tags, self-referencing URLs, and proper link structure.
Harlan WiltonHarlan Wilton14 mins read Published
What you'll learn
  • Google deprecated rel=prev/next in 2019—use self-referencing canonicals instead
  • Each paginated page should canonical to itself, not page 1
  • Infinite scroll requires hybrid approach with crawlable pagination links

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">
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'

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:

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/blog',
      component: BlogList,
      // Handles /blog?page=2
    }
  ]
})

Path segment approach:

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/blog',
      component: BlogList
    },
    {
      path: '/blog/page/:page',
      component: BlogList
    }
  ]
})

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

Google needs <a href> tags to discover paginated pages. Navigation rendered after JavaScript runs may not be crawled.

SSR/SSG approach (recommended):

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const currentPage = computed(() => Number(route.query.page) || 1)
const totalPages = 10

function goToPage(page: number) {
  router.push({ query: { page } })
}
</script>

<template>
  <nav>
    <!-- Crawlable links with href -->
    <a
      v-for="n in totalPages"
      :key="n"
      :href="`/blog?page=${n}`"
      :class="{ active: n === currentPage }"
      @click.prevent="goToPage(n)"
    >
      {{ n }}
    </a>
  </nav>
</template>

The href attribute makes links crawlable. @click.prevent enables client-side navigation for users.

SPA approach (requires prerendering):

If using a client-only SPA, you need prerendering or SSR for Google to discover pagination links. Pure SPAs without prerendering won't get pages 2+ indexed.

Pagination Component

Full example with prev/next links and numbered pages:

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

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
)

function goToPage(page: number) {
  router.push({ query: { ...route.query, page } })
}
</script>

<template>
  <nav aria-label="Pagination">
    <!-- Previous link -->
    <a
      v-if="prevPage"
      :href="`/blog?page=${prevPage}`"
      @click.prevent="goToPage(prevPage)"
    >
      Previous
    </a>

    <!-- Page numbers -->
    <a
      v-for="n in totalPages"
      :key="n"
      :href="`/blog?page=${n}`"
      :aria-current="n === currentPage ? 'page' : undefined"
      @click.prevent="goToPage(n)"
    >
      {{ n }}
    </a>

    <!-- Next link -->
    <a
      v-if="nextPage"
      :href="`/blog?page=${nextPage}`"
      @click.prevent="goToPage(nextPage)"
    >
      Next
    </a>
  </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">
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const showAll = route.query.show === 'all'

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

<template>
  <div>
    <a href="/blog?show=all">View All</a>

    <!-- 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">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()
const posts = ref([])
const page = ref(Number(route.query.page) || 1)

async function loadMore() {
  page.value++
  const newPosts = await fetchPosts(page.value)
  posts.value.push(...newPosts)

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

onMounted(async () => {
  posts.value = await fetchPosts(page.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">
      <a :href="`/blog?page=${page + 1}`">Next Page</a>
    </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

import express from 'express'

const app = express()

app.get('/api/posts', (req, res) => {
  const page = Number(req.query.page) || 1
  const limit = 10
  const offset = (page - 1) * limit

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

  res.json({
    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">
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'

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 Vue SEO.'
        : `Blog posts page ${page}. Read more about Vue 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=vue)
  • 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 -->
<a :href="`/blog?page=${page + 1}`" @click.prevent="nextPage">
Next
</a>

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. If pagination appears only in JavaScript, add SSR/prerendering.

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.

Using Nuxt?

Nuxt SEO handles pagination automatically with route rules and canonical URL generation. The <SiteLink> component ensures consistent URL formatting across paginated pages.

Learn more about Nuxt pagination →

Sources