rel=prev/next in 2019—use self-referencing canonicals insteadPagination 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.
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>
<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>
Self-referencing canonicals tell Google each page has unique content worth indexing.
Use query parameters or path segments. Both work for SEO.
| URL Pattern | Example | SEO Impact |
|---|---|---|
| Query parameter | /blog?page=2 | Good - simple, flexible |
| Path segment | /blog/page/2 | Good - cleaner URLs |
| Hash fragment | /blog#page=2 | Bad - 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.
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.
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:
View All pages work for small datasets (50 items). For large catalogs, use self-referencing canonicals.
| Pattern | SEO Impact | UX | When to Use |
|---|---|---|---|
| Pagination | Good - all pages indexable | Predictable | Catalogs, search results |
| Infinite scroll | Poor - requires special handling | Frictionless | Social feeds, inspiration |
| Load More button | Poor - unless URL changes | Balanced | Product listings |
Infinite scroll SEO challenges:
Googlebot cannot scroll. Content below the fold stays hidden. Solutions:
<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>
Load More button:
Works for UX but Googlebot can't click buttons. Use the hybrid approach above if SEO matters.
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)
})
})
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)
}
})
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
Limit queries to avoid performance issues. Use database indexes on sort columns.
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.
Don't noindex paginated pages. This loses indexed content. Google stops crawling noindexed pages, hiding your products/articles.
Only noindex if:
/blog?sort=date&order=asc&filter=vue)For most sites, keep paginated pages indexable.
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.
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
4. Site search
site:nuxtseo.com/blog inurl:page
Shows indexed paginated pages.
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 →