Skip to main content

Configure CDN Caching for self-hosted Next.js websites

Learn how to configure own, Vercel-like CDN setup, maximazing Next.js performance in a self-host environment on Cloudflare, Fastly or any other CDN.

Configure CDN Caching for self-hosted Next.js websites

This article is part of the in-depth series about self-hosted Next.js and the challenges around it.

When considering a non-Vercel hosted Next.js website setup, the first thing that comes to mind is how to ensure it will be performant enough and deliver the same or better experience for your users (and score top core web vials). Caching and a sophisticated CDN setup that Vercel provides out of the box are some of the key reasons why Next.js websites are so snappy.

So the first thing we want to focus on in this article series is how to configure CDN caching for your production Next.js deployment.

Setting the context

While technical details described in this article are generic and applicable to any CDN provider, we will cover examples from the most common CDN options - Cloudflare, Fastly, and Amazon CloudFront. We will update the articles with AWS stack specifics later and will start with Next.js caching configuration on Cloudfront and Fastly first.

Next.js also has its in-app caching layer (in-memory and file system), for the sake of narrowed focus, this article will cover the CDN caching first, the layer in front of your Next.js app that users hit the first when entering your URL in the browser.

The higher the percentage of requests your CDN can handle without referring to the origin app, the faster experience your users will get, and your server workload be more optimal.

Next.js is famous for its powerful defaults, and it's not different when it comes to default cache headers, which are well optimized with references to standards and best practices in some cases, but also opinionated decisions with their limitations in others. Let's dive in.

The tricky part - caching Next.js HTML and JSON response

To make really Next.js perform, you want your HTML and JSON files to be cached efficiently as well. HTML files being the actual document's that gets requested first in the chain, and the quicker the user's browser will download and parse it, the faster First Contentful Paint you get and the better overall loading performance.

Everything on your page will be blocked by the initial document request, including the subsequent asset downloads. Without CDN caching, even with a very fast origin server response, you can see 500-800ms Time To First byte response times due to the distance between the user location and your server alone. Add on top of that a slower internet speed, like even a decent 3G connection, will yield a download time of another 800ms for a larger-sized page.

Note that for green First Contentful Paint value (FCP in core web vitals), you need to have your document fully downloaded, parsed, and rendered something in under 1.8s.

image

Serving the document from the CDN cache will help solve both the latency and download speed, so you definitely want to have your page files cached. CDN will serve the file from the nearest location to the user, thus the benefits.

This is the tricky(ier) part to tackle when self-hosting Next.js, as this is something not working out of the box in most cases.

As Next.js stores Page representation in both HTML and JSON data, for subsequent client-side navigations, you equally want to have page data loading fast for smooth SPA navigation between pages. Usually, you would like to have both HTML and JSON of the same page to be cached with the same strategy.

Page JSON data is also that's being prefetched by default when using the Link component (both on page load and extra calls on link hover), while prefetching could be helpful with slower responses, you may not want to have this enabled for many links at the time, and if each user will initiate a couple of tens to hundreds of JSON requests just landing on the first page, your server might not enjoy this extra workload.

Beware of weak caching defaults for assets in the public folder

Before we jump into the intricacies of Next.js page caching, let's address the elephant in the room.

Built generated assets like .css and .js files are well configured in Next.js by default with 1 year-long cache TTL, but somehow all the files that you place into the public folder, have max-age=0 set by default for non-Vercel CDN use case. This means that nothing from this folder will be cached on CDN, and you have to override this in settings for a more optimized loading speed.

It is very often that the public folder is being used for storing images, and other non-processed assets. Having zero cache TTL on these will massively degrade loading performance when self-hosting Next.js websites.

See example next.config.js below, which forces a 1-year cache TTL on most common static assets across your app:

const nextConfig = {
async headers() {
if (process.env.NODE_ENV !== 'production') {
return [];
}

return [
{
source: '/:all*(css|js|gif|svg|jpg|jpeg|png|woff|woff2)',
locale: false,
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000',
}
],
}
];
}
}

Check out the accompanying demo Repo with added cache configuration for the default Next 14 boilerplate. Link to commit with code above.

There's also an ongoing discussion on the Next.js repo about this.

Configuring Cloudflare CDN for the Next.js app

Cloudflare is great, it's fast, and its network of CDN nodes is vast, and you can use it for free.

Cloudflare won't cache HTML and JSON files by default, and this is where you might eventually hit the free plan limits, but if you're okay with caching absolutely all pages (for the duration you define) or enough with provided URL filters, you can use Cloudflare Cache Rules without entering your credit card.

To configure HTML page caching (and its JSON data) on Cloudflare, go to Caching -> Cache Rules, create a new rule, configure request matching (for the whole website you can match by hostname), and Edge TTL section.

image

After enabling the rule, you should then see that sweet response header when checking your dev tools Network tab X-Cache: HIT, which means that your page is served directly from the global CDN network, without waiting from your server origin.

The only type of pages that won't be cached are "fully static" pages without any data fetching methods, but there's a simple fix to that described below.

Note that Next.js will also return other headers, like X-Nextjs-Cache, this represents the app-level caching status and has nothing to do with CDN, while it's also useful to keep track of, especially if you have slow data fetching operations.

Configuring Fastly CDN for the Next.js app

Fastly defaults to more aggressive caching rules and at its default settings would cache HTML/JSON files without modifications to Next.js default headers setup. Albeit you will have to rely on Fastly's defaults and you will not have an important feature working stale-while-revaliadte, more on this later.

In the sections below we will also cover how to more granually control cache settings overriding Next.js and CDN defaults.

Notes on Fastly vs Cloudflare differences

Compared to Cloudflare, Fastly does not have a free tier and at the time of writing, starts at 50$ minimal monthly spend. But in our experience, we've seen Fastly server response time quicker than Cloudflare, and much less aggressive cache eviction.

No CDN guarantees that your assets will be stored as long as you set in your max-age or s-maxage values, but you would at least expect multiple hours/days of cache storage, and we haven't seen free tier Cloudflare being consistent with this for lesser hit URLs. We haven't checked how this compares to paid tiers, and you can also use Cloudflare Cache Reserve to remedy this.

Stale while revalidate

The Cache-Control header directive stale-while-revalidate (SWR) is a very crucial setting for ensuring an increased HIT rate, allowing more users to be served the much quicker cached version. Essentially, SWR defines a period, for how long, after the initial cache TTL, the CDN should "remember" the most recent cache version and serve to users while re-fetching the origin on the background - if your max-age set to 1 hour and SWR to 4 hour, then users will get cached results even if page is visited the second time up to 3:59 later. Without this config, after each hour next user would fetch non cached version. And you can set it to very long values, like a whole day.

The problem is that it's not supported universally. Next.js chose to use a non-standard, not universally supported SWR definition without a value set, and even worse, this is the setting you can't easily override in Next.js.

Vercel has the best support of SWR, while other major CDNs either just recently started supporting it, with unknown reliability and unstable eviction, or do not have it implemented at all.

2023 was a fruity year for SWR support with Cloudfront (AWS family) and Netlify announcing their support. Cloudflare does not provide any official confirmation if it's supported, even while there are some related settings present. But given that Cloudflare's aggressive eviction, even default TTL is rarely respected, so I would not rely on great SWR persistence.

Next.js default stale-while-revalidate implementation

Default Next.js headers you get on pages and pages JSON data are the following:

s-maxage=31536000, stale-while-revalidate

It says - to cache the page for a year (cache TTL) and stale-while-revalidate without a value, assuming it's "indefinite" (or longest possible). While this might be true on Vercel, it's not confirmed working anywhere else. This has been raised in the open GitHub issue.

Also the 1-year max age can easily shoot you in the foot if you don't manually purge cache on each deployment, which you most likely don't if you self-host Next.js.

So let's dive into how we can (attempt to) fix this.

Caching for different Next.js page types and data-fetching strategies

As we're finally done with the intro and important context, let's get back to configuring proper cache settings for Next.js pages, covering different types of page setup options you will likely have combined in your Next.js project.

We'll cover some solutions and hacks to tackle the issue that has been raised multiple times through the years by advanced users, with the most recent ongoing open github issue dating back to Feb 2021.

You will find all code examples in the demo repo - focusreactive/demo-nextjs-cache-headers-self-host, as well as duplicated in snippets below.

Fully static pages, no data fetching

I'm not sure there's an official definition for these types of pages, but I call a "fully static" page such does not have any dynamic data at all. It's just a page with the hardcoded date on it, just a React component with static props.

These pages are compiled into static HTML and .js chunks. The JS bit, used for SPA navigations, is cached properly with long TTL and is fully immutable. And the HTML page somehow does not have any cache headers set by default with only the Vercel hosting target adding a special config for this once deployed there.

To fix that, we can use the headers setting in next.config.js:

{
source: '/fullyStaticPage',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=60, s-maxage=600, stale-while-revalidate=14400, stale-if-error=14400',
}
]
}

Next.js documentation states that setting Cache-Control headers is not possible from config, as its value will be overridden in the production build, which is true for all the cases except "fully static" pages. Hopefully, this will remain working, as so far it's the easiest fix.

You can set whatever cache strategy you wish, with lower TTL and higher SWR, without having to worry about manual cache invalidation for HTML pages on each deployment.

getServerSideProps

This one's easy, as you can have full control over what headers you wish to return in each particular case, as these pages are fully dynamic, with server processing on each request (eg no default caching and no static prebuild).

If you don't return any own Cache-Control header value, you'll see:

private, no-cache, no-store, max-age=0, must-revalidate

Which is a fair default, as there might be personalized data, which should never be stored in the public cache.

To set whatever you wish based on your case, you can drop this in your getSrverSideProps function of the page:

res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400, stale-if-error=86400');

So if your data is public, but for some reason you don't want to use getStaticProps, you can still set a good caching strategy.

getStaticProps

Now we get to the tricky part, this is where you cannot override the Cache-Control headers at all, as Next.js assumes they know better, and will set their default choice in the production build.

There are multiple ways to approach this, some more hacky than others, the ones we chose and feel to be more straightforward are:

  • Instead of overriding, you can set extra headers that will be respected by your CDN, like Surrogate-Control (Fastly) or CDN-Cache-Control (Cloudflare)
  • You can overwrite Cache-Control headers on your proxy between the user and the Next.js app (Nginx, Cloudflare workers, or Fastly/Varnish VCL scripts)

People from the original GitHub issue have been far more creative, going as far as patching build output or writing their own Next.js server.

Adding headers to getStaticProps pages

It's important to know that you will likely want to synchronize the cache settings for both HTML and JSON, as described in the intro, so next.config.js will look something like this:

{
source: '/(.*)(getStaticProps|getStaticProps\.json)',
headers: [
{
key: 'CDN-Cache-Control',
value: 'max-age=600, stale-while-revalidate=14400, stale-if-error=14400',
}
]
}

This way you can set the strategy that works for you, instead of being stuck with what Next.js forced upon you s-maxage=31536000, stale-while-revalidate. Remember, none of the Next.js native hosting options will purge cache between deploys, so could end up serving very stale content with default headers.

getStaticPaths + revalidate

This last type of page is mostly the same as getStaticProps, but includes getStaticPaths config and a dynamic route parameter. It also has a revalidate config enabled, for Incremental Static Regeneration (or ISR).

When you're using ISR (revalidate param), Next.js gives you more control over page headers, aligning the revalidation period value with the s-maxage header (TTL setting for shared cace, eg CDN only). Most likely you will want to align this value, so the timing of Next.js app-level caching is the same as CDN-level caching, and users see updated value after the set period lapsed. But we're still lacking important stale-while-revalidate value and any control over it.

Again, like in the previous examples, we wish to sync cache headers for both the HTML document and its JSON data:

{
source: '/(.*)/getStaticPaths/(.*)(json)',
headers: [
{
key: 'CDN-Cache-Control',
value: 'max-age=600, stale-while-revalidate=14400, stale-if-error=14400',
}
]
},
{
source: '/getStaticPaths/:name',
headers: [
{
key: 'CDN-Cache-Control',
value: 'max-age=600, stale-while-revalidate=14400, stale-if-error=14400',
}
]
}

There might be a more elegant way to set source, but the syntax is tricky and for the sake of example, we've simplified the snippet.

Apart from verbose config, an important note is that you need to keep aligned your max-age value with revalidate manually, as we overwrite Next.js headers this way. If 'CDN-Cache-Control is present, the CDN layer will fully ignore the original cache-control.

This and all the above config examples in real repo can be found here.

Demos

On the GitNation self-host project with Fastly CDN, at the time of writing, we're using a broader headers override, knowing that the cache policy should be the same across all the pages except for static assets:

{
source: '/((?!api$|api/).*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=60, s-maxage=600, stale-while-revalidate=14400, stale-if-error=14400',
},
{
key: 'Surrogate-Control',
value: 'max-age=600, stale-while-revalidate=14400, stale-if-error=14400',
}
]
},
{
source: '/:all*(css|js|gif|svg|jpg|jpeg|png|woff|woff2)',
locale: false,
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000',
},
{
key: 'Surrogate-Control',
value: '',
}
]
}

Some other things to note with self-host CDN

  • Cache purging should be taken care of manually, your CDN cache will persist between deploys compared to Vercel
  • etags do not seem to be respected in both Fastly and Cloudflare by default, even with new etags generated between builds by Next.js
  • Cloudflare sets browser cache TTL by default to 4 hours, extending Next.js defaults that only set the s-maxage header, unless you need this, it's better to disable this in Caching -> Configuration, or you can also override this in Cache Rules

We will update this articles as time goes on and in case of major changes to Next.js defaults and updated best practices.

WRITTEN BY

Robert Haritonov

Robert Haritonov

Engineering Manager at FocusReactive