Building a blog with Nuxt content (Part 2)

Welcome back again as we build our blog with Nuxt Content. For this part we will introduce pagination and some navigation features into our blog.

Charles Allotey
Nuxt 3

Mastering Nuxt 3 course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view the Nuxt 3 course

Welcome back to our series on Building a blog with Nuxt Content! In part 1, we laid the foundation for our project by setting up a Nuxt application and integrating Nuxt Content. We explored how to create blog posts using markdown files and learned to display this content using Nuxt Content's powerful components.

  1. Building a blog with Nuxt content (Part 1)
  2. Building a blog with Nuxt content (Part 2) 👈🏻 We are here
  3. Building a blog with Nuxt content (Part 3) - Coming soon

Now, in part 2, we'll take our blog to the next level. We'll dive deeper into Nuxt Content's features, focusing on enhancing the user experience and adding more sophisticated functionality to our blog.

We will explore some features like, pagination. We will also allow users to navigate to the next and previous articles within our article pages.

Let’s get started!

Adding pagination

I updated our blog with a few more example articles to assist with this tutorial.

Screenshot

As the number of posts increases, your blog page becomes excessively long. This may result in pages with substantial content that require extensive scrolling and loading.

Implementing pagination would enhance both user experience and performance by breaking up the content into more manageable sections.

Let’s now introduce pagination to our blog list.

Initially we were fetching our post with

const { data:blog } = await useAsyncData("blog", () => 
    queryContent("/blog")
    .find()
)

But to have the structured content we are aiming for we will first add some sorting criteria to our blog.

We added a datePublished field which contains the date published for each of our posts to help sort our list based on earliest date published to the latest.

So to fetch our posts based on our sorting condition our request becomes:

const { data:blog } = await useAsyncData("blog", () => 
    queryContent("/blog")
    .sort({ datePublished: -1 })
    .find()
)

The .sort() sorts results based on a field or fields.

Next, we need to set our max limit of each list returned for our blog:

const limit = ref(3);

const { data:blog } = await useAsyncData("blog", () => 
    queryContent("/blog")
    .sort({ datePublished: -1 })
    .limit(limit.value)
    .find()
)

The .limit() method limits number of results.

We then implement a condition using the .skip() chainable method from the queryContent() composable to skip the first 3 posts multiplied by the page number minus 1. This means if you are on page 1, no posts are skipped. If you are on page 2, the first 3 posts are skipped, and so on. This approach ensures that the first page displays the initial set of posts, the second page displays the next set, and continues accordingly.

But we need to make sure our page number is derived from our route currentPage. So we attach a computed value to handle reactive changes to our page number as our page changes

const route = useRoute()
const currentPage = computed(() => parseInt(route.query.page) || 1);
const limit = ref(3);

const { data:blog } = await useAsyncData("blog", () => 
    queryContent("/blog")
    .skip((currentPage.value - 1) * limit.value)
    .limit(limit.value)
    .find()
)

Our final code becomes:

<script setup>
const route = useRoute()
const currentPage = computed(() => parseInt(route.query.page) || 1);
const limit = ref(3);

const { data:blog } = await useAsyncData("blog", () => 
    queryContent("/blog")
    .skip((currentPage.value - 1) * limit.value)
    .limit(limit.value)
    .find()
)
</script>

<template>
  <div class="w-full">
    <div class="w-full py-40 bg-gray-100 px-4 flex flex-col items-center">
      <img src="/images/pok.webp" class="h-32 w-auto md:shrink-0" />
      <h1 class="text-4xl font-bold text-center -mt-5">My Pokemon Blog</h1>
    </div>
    <div
      class="max-w-7xl bg-white rounded-3xl shadow-md -mt-20 mx-auto p-10 grid grid-cols-3 gap-10"
    >
      <UCard v-for="item in blog" :key="item.title">
        <template #header>
          <h2 class="text-2xl font-bold">{{ item.title }}</h2>
        </template>
        <img
          src="/images/blog-pok.webp"
          class="w-full"
          :alt="`image for ${item.title} article`"
        />
        <p class="mt-4 font-medium text-gray-500">{{ item.description }}</p>
        <template #footer>
          <ULink
            :to="item._path"
            class="hover:underline text-blue-500 hover:text-blue-500"
            >Read More</ULink
          >
        </template>
      </UCard>
    </div>
  </div>
</template>

Let’s see the outcome:

Screenshot

Awesome now we can update the template to include pagination controls:

We will first introduce our Next and Previous Button to navigate between pages.

// components/pagination.vue
<script setup>
const props = defineProps({
    currentPage: {
        type: Number,
        default: 1
    },
    limit: {
        type: Number,
        default: 3
    }
})

const allArticles = await queryContent('blog').count()

// get all pages in pagination based on our limit
const totalPages = computed(() => Math.ceil(allArticles / props.limit));
</script>

<template>
    <div class="flex justify-center mt-10">
      <div class="flex items-center gap-4">
        <UButton
            icon="i-heroicons-arrow-small-left-20-solid"
            :ui="{ rounded: 'rounded-full' }"
            v-if="currentPage > 1"
            class="rtl:[&_span:first-child]:rotate-180 me-2 bg-yellow-500 hover:bg-yellow-600 disabled:invisible"
            :to="`/?page=${currentPage - 1}`"
          />
        <UButton
            icon="i-heroicons-arrow-small-right-20-solid"
            :ui="{ rounded: 'rounded-full' }"
            v-if="currentPage < totalPages"
            class="rtl:[&_span:last-child]:rotate-180 ms-2 bg-yellow-500 hover:bg-yellow-600 disabled:invisible"
            :to="`/?page=${currentPage + 1}`"
          />
      </div>
    </div>
</template>

We added a guard v-if="currentPage > 1" to make sure we do not navigate past 1 when backwards and v-if="currentPage < totalPages" to make sure we do not navigate past the number of pages.

Now to improve our pagination we will also introduce links to all individual pages in our pagination in case our user will like to navigate to navigate to a particular page.


        <NuxtLink
          v-for="i in totalPages"
          :key="i"
          :to="`/?page=${i}`"
          class="text-2xl font-semibold px-4 py-2 rounded-md border border-yellow-400 disabled:invisible"
          :class="{ 'bg-yellow-500 text-white': currentPage === i }"
        >
          {{ i }}
        </NuxtLink>

Now here is what our final page should look like.

Screenshot

I encountered a bug where the blog page did not update when the query parameters changed. After researching, I found that the issue was due to the page not re-rendering when the parameters changed. The solution was to introduce a watcher to monitor changes in the route query and force a refetching of our blog data using the refresh() composable.

const { data:blog, refresh } = await useAsyncData("blog", () => 
    queryContent("/blog")
    .skip((currentPage.value - 1) * limit.value)
    .limit(limit.value)
    .find()
)

watch(() => route.query, () => refresh())

Awesome now we can test our pagination.

Screen Recording

🎉 Awesome. Working as expected

For our next navigation feature i would like a user to be to move between articles without having to navigate back to the homepage.

Let’s first code our navigation functions. This is very simple with Nuxt Content composables

const [prev, next] = await queryContent('blog').only(['_path']).findSurround(route.path)

Nuxt Content exposes a findSurround method which filter our contnet. It takes the current route's path (route.path) as an argument. This method identifies the content item matching the current route and then finds the content items directly before and after it in the content directory's order.

We also introduce the .only method which filters the fetched content items, keeping only the _path property.

We then assign the content before to the prev variable and the next content to the next variable.

Great! Now let’s build our UI and hook in our routes.

<div class="flex items-center gap-4">
          <UButton
            icon="i-heroicons-arrow-small-left-20-solid"
            label="Previous Pokemon"
            :ui="{ rounded: 'rounded-full' }"
            :disabled="!prev"
            class="rtl:[&_span:first-child]:rotate-180 me-2 bg-yellow-500 hover:bg-yellow-600 disabled:invisible"
            @click="navigateTo(prev._path)"
          />
          <UButton
            icon="i-heroicons-arrow-small-right-20-solid"
            label="Next Pokemon"
            :disabled="!next"
            :ui="{ rounded: 'rounded-full' }"
            class="rtl:[&_span:last-child]:rotate-180 ms-2 flex-row-reverse bg-yellow-500 hover:bg-yellow-600 disabled:invisible"
            @click="navigateTo(next._path)"
          />
      </div>

Let’s test it out.

Screen Recording

Conclusion

We now have a working pagination system and users able to navigate to read new blogposts without having to go back to the homepage. This is big step in the right direction to ensuring users have a good experience in navigating through blogs and page performance is improved and is scalable as the number of posts increases.

In the final part of this series, I will cover SEO and metadata for our blogs. This is essential for making our blog easy to find and reaching a broader audience.

Nuxt Content is an outstanding tool for developers seeking an easy-to-use CMS. It simplifies the content creation process, making it effortless to create and manage your blogs. If you aim to enhance your blog or site content management, Nuxt Content is definitely worth exploring. Stay tuned for more insights and practical tips!

Useful Resources

All codes in this tutorial have been uploaded. Feel free to clone, copy, or use any part of my open-source code. Enjoy!

Github

Blog Site

Nuxt Content

Mastering Nuxt

Charles Allotey
Charles is a Frontend Developer at Vueschool. Has a passion for building great experiences and products using Vue.js and Nuxt.

Follow MasteringNuxt on