itsmnthn

Serving Nuxt Pages Pre-Compressed

Why I built nuxt-compressed-cache for Colorsinspo, and how it stores rendered pages as brotli and gzip so a cache hit only sends bytes.

Oct 27, 2026
NuxtPerformance
Back to Production Logs

Colorsinspo is a free color platform I build at Hexorial Studio. A big part of it is the catalog: a page for every color, palette, gradient and brand, all read from a database. Its sitemaps list over 23,000 URLs, and most of them are color detail pages.

That's too many pages to prerender on every deploy, and the data changes, so these pages are rendered on the server and cached. Colorsinspo runs as a plain Node server on a VPS, so the cache is mine to run.

This post covers why Nitro's built-in cache wasn't enough for me, and the module I ended up publishing: nuxt-compressed-cache.

Where I started: swr

The first version used Nitro's swr route rule, which is what most Nuxt apps reach for:

routeRules: {
  '/': { swr: 60 * 60 * 24 },
  '/items/**': { swr: 60 * 60 * 24 * 30 },
  '/collections/**': { swr: 60 * 60 * 24 * 30 },
}

It works, and it's a good default. But when I looked at what a cache hit actually does, it was more work than I expected:

  1. Nitro reads the cached entry, which is a JSON document holding the headers and the whole HTML body.
  2. It parses that JSON.
  3. It sends the HTML uncompressed.
  4. Then whatever sits in front of the server (a reverse proxy or a CDN) compresses the same page again, on every hit. Or nothing does, and the full page goes over the wire.

Colorsinspo's detail pages are big, because they ship shades, harmonies and contrast data. Here are two of them, a large one and a smaller one, compressed on my laptop with Node's zlib:

PageRaw HTMLgzip (level 6)brotli (quality 6)brotli (quality 11)
Large detail page171.7 KB25.0 KB22.3 KB19.6 KB
Smaller detail page102.0 KB21.0 KB19.3 KB17.0 KB

Compression shrinks these pages 5 to 8 times, so it isn't optional. But it isn't free either: brotli at quality 6 took about 1.6 ms for the large page, and quality 11 took 133 ms. No one runs quality 11 per request. That's the waste: the page is the same for a whole month, but it gets compressed on every request.

The idea

Compress once, when the page is rendered, and keep the compressed bytes:

  • On a miss, render the page, compress it with brotli and gzip, and store both copies next to a small metadata entry (status, headers, ETag, sizes, build id).
  • On a hit, read the metadata, pick the encoding the client accepts, and send the stored bytes as they are. No JSON parse of the page, no compression.

Since compression happens once per render instead of once per request, the quality level stops being a per-request trade-off. You can turn brotli up and pay for it once a month.

From middleware to module

I didn't start with a module. The first version was a Nitro middleware inside Colorsinspo, with its own list of cached routes. It worked, but the cache settings lived apart from the rest of the route rules, and I kept finding things that swr gives you for free and mine didn't: payloads for client-side navigation, invalidation across deploys, merging concurrent misses.

A day later I moved it into a local Nuxt module with its own compressedCache route rule, and then pulled that out into its own repo with tests and published it. Colorsinspo now just installs it from npm.

Using it

pnpm add nuxt-compressed-cache
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["nuxt-compressed-cache"],
  routeRules: {
    "/": { compressedCache: { maxAge: 60 * 60 } },
    "/blog/**": { compressedCache: { maxAge: 60 * 60 * 24 } },
  },
});

Every response the cache serves gets an x-cache header with HIT, STALE or MISS, so you can see it working with curl -I. In development and while prerendering, the module does nothing.

The rule takes the same kind of options as swr, plus a couple that I needed:

routeRules: {
  '/products/**': {
    compressedCache: {
      maxAge: 3600, // seconds a copy is fresh
      staleMaxAge: 86400, // serve stale while re-rendering in the background
      allowQuery: ['page'], // only these query params get their own entry
      bypassCookies: ['session'], // logged-in requests skip the cache
    },
  },
}

How Colorsinspo uses it

The setup looks like this, with example routes. The home page changes once a day, so it's cached for a day. The database-backed detail pages are cached for a month:

const DAY = 60 * 60 * 24;
const MONTH = DAY * 30;

routeRules: {
  '/': { prerender: false, compressedCache: { maxAge: DAY } },

  // database-backed detail pages
  '/items/**': { prerender: false, compressedCache: { maxAge: MONTH } },
  '/collections/**': { prerender: false, compressedCache: { maxAge: MONTH } },
  // ...
}

Everything that can be prerendered (tools, blog, legal pages) still is. The compressed cache is only for pages that need a server.

In production the entries live on disk, with Nitro's fs storage driver. The fs driver has no TTL, so entries stay on disk until something removes them, and with tens of thousands of possible pages that adds up. The module's built-in cleanup handles it:

// nuxt.config.ts
compressedCache: { cleanup: true },

cleanup: true turns on a scheduled task that deletes entries past their route's maxAge. If you need more control, the module also auto-imports pruneCompressedCache() into server code, so you can write your own task around it, for example one that also watches disk usage.

With a CDN in front

Colorsinspo also runs behind a CDN, which caches pages at the edge. That takes care of a lot of repeat traffic, but it doesn't make the server cache pointless. A CDN keeps a separate copy in each data center, those copies expire, and a catalog this long is mostly pages that only a few people open. The requests the CDN can't answer still reach the server, and the module makes those cheap: a hit sends stored bytes, and a miss renders and compresses once.

Why this matters on a small budget

Colorsinspo is a free product from a two-person studio, so every server bill counts. Two more things fall out of storing pages compressed, and both matter when you're trying to keep that bill small.

More pages fit in the same memory or disk. swr stores every page as a JSON entry with the full HTML inside, so the 171.7 KB page from the table takes at least 171.7 KB. The compressed cache stores 22.3 KB of brotli plus 25.0 KB of gzip, 47.3 KB for both, about 3.6 times smaller. With encodings: ['br'] it's just the 22.3 KB, about 7.7 times smaller. Whether the entries live on disk with the fs driver or in RAM with the memory driver, the same space holds several times more pages, so the same server can keep much more of the catalog warm before you need a bigger plan or a Redis instance.

Less bandwidth out of the VPS. Pages leave the server already compressed: 22.3 KB for that same page instead of 171.7 KB, about 87% less. That counts for every response that leaves the server, including the ones a CDN fetches to fill its cache, and most VPS plans cap or bill outgoing traffic. If something on your server already compresses responses, like nginx or Traefik, you were saving that bandwidth already, and what you save instead is the CPU of compressing each hit again.

None of this is a big number for one request. Over a long catalog and months of traffic, it's the difference between a server tier that's comfortable and one that's always close to its limits.

The details that took the most time

Storing compressed bytes is the easy part. These took longer:

Content negotiation. The module reads the q weights in Accept-Encoding, including *, prefers brotli on a tie, and never sends an encoding the client didn't accept. A client that accepts no compression gets a copy decompressed on the fly. If you only store br, the rare gzip-only client is converted on the fly too.

Concurrent misses. When a popular page expires and ten requests arrive at once, only one renders. The rest wait for that render. The merging happens inside each server process.

ETags and 304. Every stored page has an ETag, so a browser revalidating a page it already has gets a 304 Not Modified with no body.

Deploys. Entries are tied to the build. After a deploy, a page stored by the old build is rendered again before it's sent, so nobody gets old HTML pointing at JS chunks that no longer exist.

Payloads. On client-side navigation Nuxt doesn't fetch HTML, it fetches <page>/_payload.json?_b=<build id>. Nuxt only serves that file on routes it thinks are cached, through an isr or cache rule. cache would also turn on Nitro's own cache, so on Node presets the module adds isr: true to each compressedCache route, which those presets ignore. Payloads are then cached per build. A tab still running the previous build keeps getting the payload its code expects while that entry is fresh.

What never gets stored. Only GET requests with a 200, HTML or JSON, no Set-Cookie, and no no-store or private in Cache-Control. If a background re-render returns a 404 or a redirect, the stored copy is removed. If it returns a 5xx, the old copy stays, so a short outage doesn't empty the cache.

Invalidating from your own code

When the data behind a page changes, waiting out a month-long maxAge isn't great. The module auto-imports helpers into server code:

// serve stale once, re-render in the background
await expireCompressedCache({ path: "/items/my-item" });
await expireCompressedCache({ pathPrefix: "/items/" });

// delete, the next request renders fresh
await removeCompressedCache({ path: "/items/my-item" });

// delete expired entries, or everything older than a week
await pruneCompressedCache();
await pruneCompressedCache({ olderThan: 7 * 24 * 60 * 60 });

A path also covers that page's _payload.json.

When you don't need it

This is a niche module, and I'd rather you skip it than use it where it doesn't fit. Use plain swr, isr or nothing if:

  • you deploy to Vercel, Netlify, Cloudflare or another serverless or edge platform. They have their own caching and compression, and the module isn't built for them;
  • a CDN in front of your server already caches all your HTML;
  • your pages can be prerendered;
  • your traffic is low or your pages are cheap to render;
  • your pages change per visitor by something other than a cookie, like language or user agent. Every visitor gets the same stored copy.

It needs Nuxt 5 (Nitro 3) and a long-running Node, Bun or Deno server: a VPS, Docker, or something like Coolify.

Try it

The code, docs and tests are on GitHub, and it's on npm as nuxt-compressed-cache. If you run Nuxt on your own server and try it, I'd like to hear how it goes. Issues and PRs are welcome, and you can find me on X or LinkedIn.

Let's collaborate onyour next project.