Like what you read? Want to be updated whenever we post something new? Subscribe to our newsletter!

How I improved the performance of my Nuxt website with build time rendering

Author: Iris Meredith

Date published: 2025-06-03

I wanted to add some interactive graphs to this article, but in all honesty, I've been a bit too tired to do so, so you'll just have to deal with stats being given in text here. I swear I'll write an inline graph component at some point.

The last article I published was by far the most popular one I've written so far. It's been shared far and wide, and we're currently creeping up on almost ten thousand hits: still "niche blog" numbers, but also not nothing. It was in fact so popular that I encountered performance issues, and while previously that might be something I could have ignored, the level of readership I'm now getting means that those performance problems are likely starting to cause reader retention problems for me. In short, this is something of a problem.

Now, this level of traffic shouldn't really be causing scaling issues at all: this is, after all, largely static content being served, and I'm not exactly running this on a small server: I have two dedicated virtual CPUs and 8 GB of RAM to work with, so I have a decent number of resources. Cloudflare also does a certain amount of caching, and Caddy is in general a very capable web server. This should not be as much of an issue as it is.

Fortunately, I had a good idea as to what was causing the issue: while I ran Nuxt in Server-Side Rendering mode, the server was still generating each page's HTML on the fly as requests came in. While I knew this was inefficient, my initial readership was fairly small, and given that (for reasons specified below) improving on this was somewhat annoying, I just let it lie. And this, with the occasional hiccup, worked pretty well: when I got an unusual amount of traffic Vercel and then later Hetzner would get a little upset and I'd get people reporting 500 or 429 errors, but otherwise things were mostly fine. When one gets 10,000 hits, however, these performances issues begin to cost me a fair bit more than they used to.

So, how did I address this? The starting point, as with so many things, is to understand how we got to this point in the first place.

How we got to this point

I am, in general, a fairly prolific and consistent writer. I'm currently, on average, putting out a 3,000-5,000 word article every week, and as the stability of my situation slowly improves, this is likely to increase further. Moreover, I write about quite a wide range of topics, and as my blog grows further I'm going to want to give people the option of filtering by topic and things like that. In short, I'm going to want a CMS that's a little more sophisticated than what an average Static Site Generator offers. My choice for this was Nuxt-Content, which is file-based but allows for more sophisticated querying and filtering than something like 11ty does, while still being simple enough that I avoid having to run an entire separate service.

When building this website, developing front-end development, DevOps and infrastructure experience were also top of mind for me. While you can learn an awful lot with a static site generator, experience with a reactive framework is pretty much unavoidable if you want to compete in the current job market, and for learning things like containerisation and automated deployments with Terraform you really want some kind of server process beyond just Caddy to work with. To that end, setting the thing up with some kind of framework made sense, and seeing as React and Angular are awful and Svelte, while excellent, still makes my head spin, the obvious option was Vue/Nuxt. I also want to collect emails for my newsletter and I run a contact form: all of these things require a server process and an API, but they're minimal enough that spinning up a separate process in a different language is more effort than I really want to go to. All in all, therefore, I'm doing things that are just complex enough that using a framework makes sense.

So, those choices made sense as far as they go. Unfortunately, while Nuxt does support build-time route rendering to an extent, it can be deeply finicky, especially with dynamic routes. And of course, as I don't want to have to create a new page every time I add a new blog post, we're unavoidably going to have dynamic routes. While I had a few routes that I rendered at build-time (mostly my main page and my RSS feed), listing each new blog post individually is not, on the whole, much better than creating a whole new page each time. It was thus easier, on the whole, to just ignore the problem. Unfortunately, I probably can't any more, which means that I've had to bite the bullet and actually optimise my website for performance.

Collecting data

With an issue like this, it's a fool's errand to attempt to solve the issue without first finding out what's going wrong. In a situation where my website begins to fail because of excessive load, therefore, we need to do some load testing. The tool I chose to do this with was Locust, a python-based load testing tool that allows you to specify user behaviour in a python file. As Nuxt projects tend to push you towards building a monorepo, I created a tests/load directory for Locust to live in, then ran uv init followed by uv add locust in the directory to install locust. We can now run Locust by running uv run locust and then navigating to localhost:8089 to start a load testing run.

Before we can actually run a test, we need to define our user behaviour in a file in the load directory called locustfile.py. Mine looks like this:

from locust import HttpUser, task, between

class BlogUser(HttpUser):
    wait_time = between(1, 5)
    @task
    def get_blog_post(self):
        self.client.get("/blog/keeping_up_appearances")

As the main performance issues I've encountered tend to be with too many people repeatedly hitting one blog post, that's the situation I've chosen to replicate here. What we've done here is defined our user by extending Locust's HttpUser class. Each user Locust spawns will then attempt to fetch my latest article every one to five seconds.

We also need a local version of our (built) website to test this on. Fortunately enough, I have a docker compose file in my root directory for precisely this purpose, so getting that running is simply a matter of running sudo docker compose up -d --build in the root directory.

We can then run Locust, aiming for a maximum of a hundred concurrent users, with one new user spawning every second, and we immediately find that we have some serious issues: it's taking an average of 85 ms for the server to respond to each request, and at about ten requests/second we start seeing the server return a mixture of 500 and 429 errors. This means that the rendering cost seems to be high enough that we need to consider trying a prerendering strategy, because this is a major bottleneck. To test this hypothesis, we can try prerendering that specific page by adding it to the prerender list in nuxt.config.ts:

nitro: {
    prerender: {
      routes: ["/rss", "/", "/blog/keeping_up_appearances"],
    },
  },

When we rebuild and run this, we see a significant level of improvement: median response time has decreased to 5 ms, which is much, much faster. We're also easily able to deal with up to three hundred requests per second and a similar number of concurrent users (I tried to push it higher, but as I'm running Locust on my development machine, too many concurrent users will eat all the CPU capacity allocated to it and eventually cause a crash). This means that prerendering at build time will remove at least one major bottleneck from the system and likely significantly improve system performance. Which in turn raises the question: how exactly are we going to do this?

Setting up build-time rendering

In general, Nuxt has fairly decent support for build time rendering. The simplest option is to just run npm run generate, which renders the entire website into an output directory containing a static site. If your site is entirely static, this works well, but for obvious reasons, if you have some server routes defined, it simply doesn't work.

The next option is the one listed above, where you manually list routes to be rendered at build time. We've already gone through why this is impractical for my case, but in short, I'd need to add a new entry each time I add a blog post, which is doable, but also a pain in the ass and kind of error-prone. Something more elegant is needed.

The next two options involve the use of route rules and config hooks respectively. While they're methods that can work, they're also troublesome to configure, as they require the existence of an API that is accessible at build time and can give you a comprehensive list of articles. If you're using a headless API like Strapi, this can work quite well, but using Nuxt-Content makes setting this up downright irritating.

This leaves the crawl option. While this wasn't available when I started building this website, Nuxt-Content now supports crawl-based rendering at build-time. In order to enable this, we simply need to update our prerender settings to the following:

  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ["/rss", "/", "/blog"],
    },
  },

With this setting in place, Nuxt will begin by prerendering the index page, and will then recursively prerender each page that the index page links to. This allows all of the page routes to be prerendered without hitting any of the API routes or trying to turn anything into a statically rendered HTML file that shouldn't be that thing. This happily works quite well, and doesn't do anything annoying or require me to do anything annoying, but there is a catch: pagination.

First off, thank you to all of you who have supported me enough to cause this problem. It's given me an excellent opportunity to talk about load testing and performance optimisations for this kind of app, and it's also let me write a bit more about doing stuff with Vue and Nuxt, both of which are beneficial. If you're new to this blog and wish to get regular updates about what I'm doing, you can subscribe to email updates below.

If you're feeling generous, you can also support my writing through Patreon, Liberapay or Stripe. My initial goal is to bring in $500 a month from writing income (that'll let me comfortably pay for power and internet from that income without fussing and do a monthly bulk food shop), and I've made significant progress towards that outcome (I'm about 25% of the way there between all my sources), so contributing now could plausibly do a lot to help get me over that line fairly soon.

As you'll be aware of, once you've written a certain number of articles, displaying them all on the same endless webpage becomes a pain in the behind. This is doubly important if, like my website, you present readers with a fairly rich post summary. I therefore built a pagination system for my website a while ago, but it wasn't a particularly good implementation. I had a component called SummaryList.vue, which looked like this:

<script setup>
import { ref, computed } from "vue";

const posts_per_page = 5;
const n_posts = await queryContent("blog").count();
const n_pages = Math.ceil(n_posts / posts_per_page);
const page = ref(0);

const query = computed(() => {
  return {
    path: "/blog",
    limit: posts_per_page,
    skip: posts_per_page * page.value,
    sort: [{ date_published: -1 }],
  };
});

function increment() {
  if (page.value < n_pages - 1) {
    page.value++;
    window.scrollTo(0, 0);
  }
}

function decrement() {
  if (page.value > 0) {
    page.value--;
    window.scrollTo(0, 0);
  }
}
</script>

<template>
  <ContentList :query="query" v-slot="{ list }">
    <div id="post_container" v-for="post in list" :key="post._path">
      <PostSummary :post="post"></PostSummary>
    </div>
  </ContentList>

  <div id="pagination">
    <v-btn
      v-show="page != 0"
      class="nav_button"
      id="newer_button"
      @click="decrement"
      >Newer posts</v-btn
    >
    <span id="page_display">Page: {{ page + 1 }} of {{ n_pages }}</span>
    <v-btn
      v-show="page != n_pages - 1"
      class="nav_button"
      id="older_button"
      @click="increment"
      >Older posts</v-btn
    >
  </div>
</template>

<style scoped lang="scss">
#post_container {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  margin-bottom: 20px;
}

#pagination {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  justify-items: center;
  margin-left: 10px;
  margin-right: 10px;
  margin-bottom: 10px;
  gap: 30px;
}

#newer_button {
  grid-column: 1;
}

#page_display {
  grid-column: 2;
}

#older_button {
  grid-column: 3;
}

.nav_button {
  font-family: Aviva;
  color: $secondary-color-5;
  background-color: $dark-color;
  box-shadow: 5px 5px $secondary-color-5;
  border-radius: 3px;
}
</style>

If you read through this mess, you'll notice that it's dynamically querying for a subset of the most recent posts and rendering them on component creation. When one clicks the "Older posts" or "Newer posts" button, it recalculates which subset to display and re-renders the display components. This, to put it delicately, probably wasn't the best way to do things: it doesn't cache well, it isn't great for web crawlers or search engines trying to index my website, and it's just generally kind of janky. More importantly for us, it makes it impossible for the crawler to reach all of my blog posts, meaning that crawl-based prerendering isn't going to work with this system for pagination. Clearly we need a better approach.

The way I've resolved this is as follows. First off, the subsetting logic needed to be taken out of this component and parameters needed to be passed as props. The new SummaryList.vue component then looks like this:

<script setup>
import { computed } from "vue";

const props = defineProps(["limit_query", "skip_query"]);

const query = computed(() => {
  return {
    path: "/blog",
    limit: props.limit_query,
    skip: props.skip_query,
    sort: [{ date_published: -1 }],
  };
});
</script>

<template>
  <ContentList :query="query" v-slot="{ list }">
    <div id="post_container" v-for="post in list" :key="post._path">
      <PostSummary :post="post"></PostSummary>
    </div>
  </ContentList>
</template>

<style scoped lang="scss">
#post_container {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  margin-bottom: 20px;
}

#pagination {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  justify-items: center;
  margin-left: 10px;
  margin-right: 10px;
  margin-bottom: 10px;
  gap: 30px;
}

#newer_button {
  grid-column: 1;
}

#page_display {
  grid-column: 2;
}

#older_button {
  grid-column: 3;
}

.nav_button {
  font-family: Aviva;
  color: $secondary-color-5;
  background-color: $dark-color;
  box-shadow: 5px 5px $secondary-color-5;
  border-radius: 3px;
}
</style>

This is a much more sensible component, as it fetches and renders a defined subset of the articles each time and doesn't have any control logic in it. Now that that's been done, the next step is to look at the page that this is displayed on.

The root page for all of my blog posts is at /blog (some of my more eagle-eyed readers will probably register that this used to be at /blog/posts: that happened because I forgot how to create an index page and I've since set up a redirect). With the dynamic method, this was the only page that displayed my blog posts. There are, however, a number of reasons why we'd like an individual route for each page of articles: SEO, reader experience (I've received a fair few questions about the original implementation) and importantly for us, all of those pages can be prerendered at build time, which means that, thanks to the magic of recursion, all of the blog posts will be as well. I thus created a dynamic page at /blog/page/[page], with page being the page number. The source for that page looks like this:

<script setup>
import { computed } from "vue";
import { useDisplay } from "vuetify";

const route = useRoute();

useHead({
  title: "deadSimpleTech - Blog posts",
  bodyAttrs: {
    class: "nuxt_body",
  },
  meta: [
    {
      name: "description",
      content: "A blog about tech and the modern workplace by Iris Meredith",
    },
  ],
});

const posts_per_page = 5;
const n_posts = await queryContent("blog").count();
const n_pages = Math.ceil(n_posts / posts_per_page);
const page = ref(Math.floor(route.params.page));

function increment() {
  if (page.value < n_pages - 1) {
    page.value++;
    window.location.href = "/blog/page/" + page.value;
  }
}

function decrement() {
  if (page.value > 0) {
    page.value--;
    if (page.value > 0) {
      window.location.href = "/blog/page/" + page.value;
    } else {
      window.location.href = "/blog";
    }
  }
}

const name = useDisplay();

const wide = computed(() => {
  // name is reactive and
  // must use .value
  switch (name.value) {
    case "xs":
      return false;
    case "sm":
      return false;
    case "md":
      return false;
    case "lg":
      return true;
    case "xl":
      return true;
    case "xxl":
      return true;
  }

  return true;
});
</script>

<template>
  <Navbar />
  <div id="grid">
    <div id="nav_stuff">
      <InfoBlock v-if="wide" path="postheader"></InfoBlock>
      <SubscriptionBox></SubscriptionBox>
    </div>
    <div>
      <SummaryList
        :limit_query="posts_per_page"
        :skip_query="posts_per_page * page"
      ></SummaryList>
      <div id="pagination">
        <v-btn
          v-show="page != 0"
          class="nav_button"
          id="newer_button"
          @click="decrement"
          >Newer posts</v-btn
        >
        <div id="page_display">
          <span v-for="n in n_pages">
            <NuxtLink :to="'/blog/page/' + (n - 1)">{{ n }}</NuxtLink>
          </span>
        </div>
        <v-btn
          v-show="page != n_pages - 1"
          class="nav_button"
          id="older_button"
          @click="increment"
          >Older posts</v-btn
        >
      </div>
    </div>
    <div id="notes_and_info">
      <InfoBlock v-if="wide" path="_popular_posts" />
    </div>
  </div>
</template>

<style scoped lang="scss">
#grid {
  @media screen and (min-width: 1280px) {
    display: grid;
    grid-template-columns: 1fr 600px 1fr;
    column-gap: 20px;
  }

  @media screen and (max-width: 1279px) {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
  }
}

#nav_stuff {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 40px;
  margin-right: 20px;
  margin-top: 100px;
}

#notes_and_info {
  margin-top: 400px;
  margin-left: 20px;
  margin-right: 20px;

  @media screen and (max-width: 1279px) {
    margin-top: 20px;
    margin-bottom: 20px;
  }
}

#pagination {
  display: grid;
  grid-template-columns: 1fr 2fr 1fr;
  justify-items: center;
  margin-left: 10px;
  margin-right: 10px;
  margin-bottom: 10px;
  gap: 30px;
}

#newer_button {
  grid-column: 1;
}

#page_display {
  grid-column: 2;
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  justify-self: stretch;
}

#older_button {
  grid-column: 3;
}

.nav_button {
  font-family: Aviva;
  color: $secondary-color-5;
  background-color: $dark-color;
  box-shadow: 5px 5px $secondary-color-5;
  border-radius: 3px;
}
</style>

Here we calculate the total number of pages that we'll need to display all of our articles, assuming five articles per page, and then use that along with the page number passed in the route parameters in order to calculate the limit and skip parameters required for Nuxt-Content's queryContent utility (this could be done more efficiently somewhere outside of this component, but that's a task for another day. I swear I'll have website code that doesn't look like a dumpster fire someday!). Our older posts and newer posts can now, rather than triggering a reactivity cascade based on the page ref, simply redirect us to another dynamic page by calculating the next or the previous page from the current one. We've also added a direct link to each page at the bottom of the pagination, which means that the crawler can easily find and prerender each page of articles, and then by extension the articles themselves. Finally, we need to set up a redirect so that people trying to go to /blog/page/0 are redirected to /blog. We can do this by adding the following block to nuxt.config.ts:

  routeRules: {
    "/blog/posts": { redirect: "/blog" },
    "/blog/page/0": { redirect: "/blog" },
  },

We can then proceed to build the updated version of the website as usual and find that it works. Hooray! But did it solve the problem?

Testing the new solution

Standing up the new build, we can then point Locust at it in the same way, trying a variety of different routes. Doing so gives a result identical to the build prerendering data shown earlier in this article, and indicates similar performance characteristics. The new build-time rendering strategy worked. This means that we can now deploy our new website, and having done that, point Locust at our actual deployment. When we do this, we can see that the mean time to complete a request (480 ms) is quite a lot higher, which makes sense considering that the request needs to travel between Hamilton and Helsinki and back. Still, less than half a second's latency isn't bad, and as the tests show the deployment seems to be able to comfortably handle several hundred requests per second without failures. Of course, I'll not know for sure until another article I write goes a little viral, but for now it certainly seems that my current deployment is much, much more performant than my previous one was.

And there we have it: a simple fix for a performance issue that's been bugging me on-and-off for quite some time!

I offer professional DevOps mentoring and coaching services. If you're an early-career engineer or someone who writes code but not in a production engineering setting and wants to learn the technical soft skills associated with doing software engineering well, please write to me at [email protected] and we can set up an initial conversation.

Otherwise, if you found this article interesting, insightful or inflammatory, please share it to social media using one of the links below or contribute to my Liberapay or Patreon (these help me even out my consulting income and let me keep writing). We also like getting and respond to reader mail: please direct that to [email protected] should you feel moved to write.

Share:

Follow me: Mastodon | Bluesky | LinkedIn | RSS | Email

Support my work: Liberapay | Patreon | Donate

This website is part of the Epesooj Webring

Previous website | Next website