Pagination Issue – Incorrect Page State When Switching Categories (Only in Production)

I am experiencing a pagination caching issue in Nuxt 3 while hosting on Netlify. When I navigate from page 2 of a category to another category and then return, the previous page (2) is retained, instead of resetting to page 1.

You can try it here:

This issue only occurs in production, but works correctly in development. Expected Behavior: When switching categories, pagination should reset to page 1. When returning to a category, page 1 articles should be displayed, not a previously viewed page.

Steps to Reproduce:

  1. Visit category A, navigate to page 2.
  2. Switch to category B.
  3. Return to category A.
  4. The articles displayed are still from page 2, instead of resetting to page 1.

Relevant Code:

<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useRoute, useRouter, useAsyncData, useSeoMeta, createError } from '#app'

const route = useRoute()
const router = useRouter()
const category = route.params.category as string
const page = ref(parseInt(route.query.page as string) || 1)
const itemsPerPage = 9

// Watch for changes in the page and update the URL
watch(() => route.query.page, async (newPage) => {
  page.value = parseInt(newPage as string) || 1
  await refresh()

  // Ensure scrolling happens after the DOM updates
  await nextTick()
  window.scrollTo({ top: 0, behavior: 'smooth' })
})

// Fetch the latest featured article separately
const { data: firstArticle } = await useAsyncData(`content:category:${category}:featured`, async () => {
  const [article] = await queryContent('/articles')
    .where({ categories: { $contains: category }, published: { $ne: false } })
    .sort({ publishedAt: -1 })
    .limit(1)
    .find()

  return article || null
})

// Fetch total number of articles excluding the featured one
const { data: total } = await useAsyncData(`content:category:${category}:total`, async () => {
  const count = await queryContent('/articles')
    .where({ 
      categories: { $contains: category }, 
      published: { $ne: false }, 
      _path: { $ne: firstArticle.value?._path } // Exclude featured article
    })
    .count()
  return count
})

// Fetch paginated articles while skipping the featured article
const { data: remainingArticles, refresh } = await useAsyncData(
  `content:category:${category}:page:${page.value}`,
  async () => {
    return queryContent('/articles')
      .where({ 
        categories: { $contains: category }, 
        published: { $ne: false }, 
        _path: { $ne: firstArticle.value?._path } // Exclude featured article
      })
      .only(['_path', 'title', 'categories', 'description', 'publishedAt', 'image', 'authors'])
      .sort({ publishedAt: -1 })
      .limit(itemsPerPage)
      .skip(itemsPerPage * (page.value - 1)) // Ensure pagination logic still works
      .find()
  }
)

if (!remainingArticles.value) {
  throw createError({
    statusCode: 404,
    message: 'Category not found',
  })
}

const appConfig = useAppConfig()

const formattedCategory = formatCategory(category)
const title = `${formattedCategory} - ${appConfig.app.name}`
const categoryDescriptions = appConfig.categories

const description = categoryDescriptions[formattedCategory] || `All articles related to ${formattedCategory}`
const runtimeConfig = useRuntimeConfig()
useSeoMeta({
  title: title,
  description,
  ogTitle: title,
  ogDescription: description,
  ogImage: runtimeConfig.app.url + '/images/the_fineprint.jpeg',
  twitterTitle: title,
  twitterDescription: description,
  twitterImage: runtimeConfig.app.url + '/images/the_fineprint.jpeg',
  twitterCard: 'summary',
})
</script>

<template>
  <UContainer>
    <UPage v-if="remainingArticles">
      <UPageHeader
        headline="Category"
        :title="formattedCategory"
        :ui="{ headline: 'text-primary-900 dark:text-white', description: 'text-primary-900 dark:text-white' }"
      />

      <UPageBody v-if="firstArticle">
        <!-- Featured Article (First Article) -->
        <div class="group relative grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 items-center xl:items-start">
          <div class="md:col-start-1 md:row-start-1 flex flex-col">
            <h3 class="text-xl font-semibold text-primary-900 dark:text-white">
              <NuxtLink :to="firstArticle._path">
                <span class="absolute inset-0 z-10" />
                <span>{{ firstArticle.title }}</span>
              </NuxtLink>
            </h3>

            <p class="mt-4 text-primary-500 text-primary-900 dark:text-white">
              {{ firstArticle.description }}
            </p>

            <dl
              v-if="firstArticle.publishedAt || firstArticle.authors"
              class="mt-6 flex justify-between items-center text-sm text-primary-900 dark:text-white"
            >
              <template v-if="firstArticle.authors">
                <dt class="sr-only">
                  Author
                </dt>
                <dd>
                  <ArticleCardAuthors :authors="firstArticle.authors" />
                </dd>
              </template>
              <template v-if="firstArticle.publishedAt">
                <dt class="sr-only">
                  Published at
                </dt>
                <dd>
                  <ArticleCardDate :date="firstArticle.publishedAt" />
                </dd>
              </template>
            </dl>
          </div>

          <div class="overflow-hidden rounded-md row-start-1 md:col-start-2 xl:col-span-2">
            <!-- Do not use NuxtImg to avoid breaking images. -->
            <img
              v-if="firstArticle.image"
              :src="firstArticle.image.src"
              :alt="firstArticle.image.alt"
              class="aspect-[16/9] object-cover transition-transform transform ease-in duration-300 group-hover:scale-[102%]"
              aria-hidden="true"
            >
          </div>
        </div>

        <UPageGrid class="mt-8">
          <ArticleCard
            v-for="article in remainingArticles"
            :key="article._path"
            :to="article._path!"
            :title="article.title!"
            :description="article.description"
            :date="article.publishedAt"
            :image="article.image"
            :authors="article.authors"
          />
        </UPageGrid>
      </UPageBody>

      <!-- Pagination Component -->
      <UPagination
        v-if="total > itemsPerPage"
        :total="total"
        :page-count="itemsPerPage"
        v-model="page"
        :to="(page: number) => ({
          query: { page },
          hash: '#links'
        })"
      />
    </UPage>
  </UContainer>
</template>

I am struggling for days now I can’t get it to work. Help is really appreciated :pray:

I created a minimal runnable reproduction, but I couldn’t replicate the issue. It seems like it might be related to Netlify’s configuration. You can see the reproduction here: https://stackblitz.com/edit/nuxt-ui-yzexmk2l?file=README.md.

Try adding netlify-vary header: Caching | Netlify Docs

1 Like

Thank you for the suggestion! I saw the netlify-vary header mentioned in the docs, but I’m not entirely sure how to implement it in my Nuxt 3 app. Could you provide a more detailed example of how to set it up? Where do I have to add this?

Added it in my netlify.toml, but still the same issue.

You should be using this: useResponseHeader · Nuxt Composables

Tried to use it with what you said, but no success. I got [nuxt] Setting response headers is not supported in the browser.

// Set the a custom response header
const header = useResponseHeader('X-My-Header');
header.value = 'my-value';

Now I used a middleware, but not success either.

api/middleware/set-header.ts
export default defineEventHandler((event) => {
    setResponseHeader(event, 'Netlify-Vary', 'query=item_id|page|per_page');
});

Now I also get a Hydration completed but contains mismatches. if I call a category with page parameter. For example: Real Estate - The Fineprint

At this point, it would be a question for Nuxt then. Basically, you need to set the netlify-vary header. If the composable provided by Nuxt doesn’t work, that’s something you’d have to check with them.

I set the header via middleware, but the problem persists. Has something to do with netlify.

Works perfectly fine in development, but doesn’t in production.

What am I doing wrong here? I set the Netlify-Vary header as instructed, but the issue persists.

Please I appreciate any help the app is already in production and the pagination should work properly.

Lets see if it is cache; return these headers from your serverless functions.
‘Netlify-CDN-Cache-Control’: ‘public, max-age=0, must-revalidate’,
‘Cache-Control’: ‘public, max-age=0, must-revalidate’

1 Like

Did it like so now, but the problem persists in production.

export default defineEventHandler((event) => {
    setResponseHeader(event, 'Netlify-Vary', 'query=item_id|page|per_page');
    setResponseHeader(event, 'Netlify-CDN-Cache-Control', 'public, max-age=0, must-revalidate');
    setResponseHeader(event, 'Cache-Control', 'public, max-age=0, must-revalidate');
});

the only other thing I can think of is to wild card the query params; in case the params don’t match for some reason e.g.
setResponseHeader(event, ‘Netlify-Vary’, ‘query’);

Thanks for your answer, but problem persists.