---
title: "Pagination SEO for Vue Applications"
description: "How to implement SEO-friendly pagination in Vue using canonical tags, self-referencing URLs, and proper link structure."
canonical_url: "https://nuxtseo.com/learn-seo/vue/routes-and-rendering/pagination"
last_updated: "2026-05-06T18:49:08.130Z"
---

<key-takeaways>

- 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

</key-takeaways>

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](https://developers.google.com/search/blog/2011/09/pagination-with-relnext-and-relprev) `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.

![Pagination Canonical Flow](/images/learn-seo/vue/pagination-canonical-flow.svg)

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

<code-group>

```vue [✅ Correct]
<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>
```

```vue [❌ Wrong]
<script setup lang="ts">
import { useHead } from '@unhead/vue'

// Don't do this - hides pages 2+ from search
useHead({
  link: [
    {
      rel: 'canonical',
      href: 'https://nuxtseo.com/blog' // Always page 1
    }
  ]
})
</script>
```

</code-group>

[Self-referencing canonicals](https://www.semrush.com/blog/pagination-seo/) tell Google each page has unique content worth indexing.

## Pagination URL Structure

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

<table>
<thead>
  <tr>
    <th>
      URL Pattern
    </th>
    
    <th>
      Example
    </th>
    
    <th>
      SEO Impact
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Query parameter
    </td>
    
    <td>
      <code>
        /blog?page=2
      </code>
    </td>
    
    <td>
      Good - simple, flexible
    </td>
  </tr>
  
  <tr>
    <td>
      Path segment
    </td>
    
    <td>
      <code>
        /blog/page/2
      </code>
    </td>
    
    <td>
      Good - cleaner URLs
    </td>
  </tr>
  
  <tr>
    <td>
      Hash fragment
    </td>
    
    <td>
      <code>
        /blog#page=2
      </code>
    </td>
    
    <td>
      Bad - Google ignores <code>
        #
      </code>
    </td>
  </tr>
</tbody>
</table>

**Query parameter approach:**

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

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

**Path segment approach:**

```ts
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 `#`](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading).

## Crawlable Links

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

**SSR/SSG approach (recommended):**

```vue
<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) {
  navigateTo({ 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](/learn-seo/vue/spa/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:

```vue
<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) {
  navigateTo({ 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](https://www.amsive.com/insights/seo/how-to-correctly-implement-pagination-for-seo-user-experience/) 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.

```vue
<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](https://seotactica.com/learn/canonical-pagination/) for small datasets (50 items). For large catalogs, use self-referencing canonicals.

## Infinite Scroll vs Pagination

![Pagination vs Infinite Scroll Decision](/images/learn-seo/vue/pagination-vs-infinite-scroll.svg)

<table>
<thead>
  <tr>
    <th>
      Pattern
    </th>
    
    <th>
      SEO Impact
    </th>
    
    <th>
      UX
    </th>
    
    <th>
      When to Use
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Pagination
    </td>
    
    <td>
      Good - all pages indexable
    </td>
    
    <td>
      Predictable
    </td>
    
    <td>
      Catalogs, search results
    </td>
  </tr>
  
  <tr>
    <td>
      Infinite scroll
    </td>
    
    <td>
      Poor - requires special handling
    </td>
    
    <td>
      Frictionless
    </td>
    
    <td>
      Social feeds, inspiration
    </td>
  </tr>
  
  <tr>
    <td>
      Load More button
    </td>
    
    <td>
      Poor - unless URL changes
    </td>
    
    <td>
      Balanced
    </td>
    
    <td>
      Product listings
    </td>
  </tr>
</tbody>
</table>

**Infinite scroll SEO challenges:**

[Googlebot cannot scroll](https://www.seoclarity.net/blog/pagination-vs-infinite-scroll). Content below the fold stays hidden. Solutions:

1. **Hybrid approach** - Infinite scroll for users, paginated URLs for crawlers:

```vue
<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
  await navigateTo({ query: { page: page.value } }, { replace: true })
}

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](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading) alongside infinite scroll.

**Load More button:**

Works for UX but [Googlebot can't click buttons](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading). Use the hybrid approach above if SEO matters.

## Server-Side Pagination

<code-group>

```ts [Express]
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)
  })
})
```

```ts [H3]
import { defineEventHandler, getQuery } from 'h3'

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)
  }
})
```

```ts [Vite]
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true
      }
    }
  }
})
```

</code-group>

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

## Meta Titles and Descriptions

Differentiate each paginated page for SEO:

```vue
<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](/learn-seo/vue/controlling-crawlers/duplicate-content) confusion ([source](https://www.searchenginejournal.com/technical-seo/pagination/)).

## Noindex on Paginated Pages

**Don't** noindex paginated pages. [This loses indexed content](https://www.seoclarity.net/blog/pagination-seo). 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](https://ahrefs.com/blog/rel-prev-next-pagination/).

## Common Mistakes

**Mistake 1: Canonicalizing all pages to page 1**

<code-group>

```vue [❌ Bad]
<script setup lang="ts">
import { useHead } from '@unhead/vue'

// Don't do this - tells Google pages 2+ are duplicates
useHead({
  link: [
    { rel: 'canonical', href: 'https://nuxtseo.com/blog' }
  ]
})
</script>
```

```vue [✅ Good]
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const page = Number(route.query.page) || 1

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

</code-group>

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

**Mistake 2: Using hash fragments**

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

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

**Mistake 3: Client-only pagination links**

<code-group>

```vue [❌ Bad]
<template>
  <!-- Not crawlable - Googlebot can't click buttons -->
  <button @click="nextPage">
    Next
  </button>
</template>
```

```vue [✅ Good]
<template>
  <!-- Crawlable with href attribute -->
  <a :href="`/blog?page=${page + 1}`" @click.prevent="nextPage">
    Next
  </a>
</template>
```

</code-group>

**Mistake 4: Inconsistent trailing slashes**

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

Pick one format. See [trailing slashes guide](/learn-seo/vue/routes-and-rendering/trailing-slashes).

**Mistake 5: Blocking pagination in robots.txt**

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

[Don't block pagination URLs](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading). Google needs to crawl them.

## Testing Pagination SEO

**1. Check canonical tags**

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

Should return:

```html
<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**

```text
site:nuxtseo.com/blog inurl:page
```

Shows indexed paginated pages.

## Using Nuxt?

Nuxt SEO handles pagination automatically with route rules and canonical URL generation.

[Learn more about Nuxt pagination →](/learn-seo/nuxt/routes-and-rendering)
