Next.js CDN Caching For Self-hosted Websites - CloudFlare and others
Learn about CDN caching nuances for React web apps and self-hosted Next.js, maximazing global performance while reducing server strain and costs.

CDN caching (Contend Delivery Network) became essential for any modern website and public web application, with free-tier products like Cloudflare and CDN-native frameworks like Next.js, internet users quickly became addicted to lighting fast load times. Not to mention massive costs savings on compute and bandwidth tarrifs.
In this article we'll go through the basics of why you need CDN and how to sort it out when you host your apps outside platforms like Vercel, who does the heavy lifting for you.
With self hosting going more mainstream, let's dive into nuances of fine-tuning your Next.js for best CDN performance with meaningful cost management in mind.
This article is part of the in-depth series about self-hosted Next.js and the challenges around it.
CDN Caching for websites
In essense, CDN is a cache layer that sits between your server and the end customer visiting your website or a web app. The more pages and loadable assets you can share between users, the more benefitial it is to have such layer. CDN is primarely used to increase load time performance, making your files available through a global network of cache nodes, instead of single server location. But also the less requests your origin host is serving, the less expensive compute your require, thus lesser hosting bills.
For assets like scripts (.js
files), images, fonts - CDN is absolutely essential, as these assets are most commonly are the same for every user and do not change between deploys or can stay the same for months and years.
The quicker such assets will get downloaded and executed in the user browsers the faster Core Web Vitals metrics and better UX - especially important for users on slower connections (think 3G/4G mobile network, or tube commuters), and from locations further from you origin host, more often than not located in a signle region.
But it does not end on static files, you also want to cache whole pages (HTML documents) on a CDN, as this is the very first thing browers download when visiting your domain, improving critical loading path and Time To First Byte. Next.js framework made this increasingly simpler, and went event further with React Server Components architecture allowing to mix cacheble public components, with personalised dynamic content not suitable for CDN.
Configuring Next.js CDN Caching
Now, if you host your app on Vercel, most of the nuances would be covered for you at the platform level, natively supporting various Next.js page and asset loading strategies. We'll focus on the essential parts you need to take care of yourself when self-hosting Next.js app on your own cloud.
Next.js also has other caching mechanisms like Data Cache, memoization and etc. 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.
If you're looking how to organize Data Cache and ISR cache with self-hosted Next.js, you will need to use a custom
cacheHandler
as described in docs, together with self-hosted database, like Redis for example.
In this article we will focus mainly on the latest App Router with some references to Pages router, as it has the most recent featureset and is stable in Next.js v15 at the time of writing.
Next.js Static Export Caching
Next.js still supports a feature that allows to export all pages as static HTML files, that can be uploaded to any hosting and served without any runtime. If that's your use case, there will be much less specifics when configuring CDN, as you would likely default to caching all files aggressively and revalidate all files on each deploy.
But as most benefits of Next.js comes from its combined static/dynamic architecture, read on how to tackle most common self-hosted situations with full features support.
Note for Next.js 14.x users
If you're on earlier version of Next.js 14.x, upgrade to latest, or refer to solution with an additional cache header for CDN that is prioritized over Cache-control
, initial demo here, as covered in the early version of this article.
The change that would allow overwriting Cache-control
header for pages in Next.js has been eventually merged and released latet in 14.x branch.
Latest 14.x Next.js realease finally allows Cache-control
headers override. See demo example in the corresponding branch.
The tricky part - caching Next.js pages
To properly optimize Next.js performance, we should focus on the very first asset your website visiter downloads - the document
, or the page itself (and it's HTML source). Properly cached pages serve as the foundation for your application's loading experience.
The faster browsers can download and parse initial page document, the better your First Contentful Paint metrics will be, improving overall loading performance. Every element on your page depends on this initial document request, including subsequent asset downloads.
Without CDN caching, even with a fast origin server, you may experience 500-800ms Time To First Byte due simply to the physical distance between users and your server. When combined with moderate connection speeds (like 3G), larger pages can require an additional 800ms just for downloading.
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
CDN caching addresses both latency and download speed challenges by serving files from locations nearest to users. However, this represents a challenging aspect of self-hosting Next.js, as it typically doesn't work automatically in most configurations.
Prior to App Rounter, Next.js delivered page data in both HTML and JSON formats, with the latter supporting subsequent client-side navigation. In App Router, instead of JSON, a new type of document been introduced (text/x-component
) serving simillar purpose of pre-fetching data before SPA navigation.
When configuring cache headers for pages, their "data" representation in RSC payload is aligned. But do remember that page itself and its RSC payload are different URLs and cached separately.
A note on prefetch
Next.js default <Link>
component has prefetch
enabled for all links, unless overriden. Prior to App Router, it's been initiating fetching of JSON page data representation, and now it's fetchign text/x-component
document (page url + ?_rsc
).
Page data is automatically prefetched when using the Link
component. While prefetching helps speed up SPA navigations, enabling it for numerous links simultaneously can create problems. If each user triggers dozens or hundreds of additional requests upon landing on the first page, your server may struggle with this additional workload.
To better control website bandwith, and compute load, until you're fully confident in efficiency of your cache setup, it would be a good idea to disable prefetch on all <Link>
components and keep them only on critical user paths. Later inroducing prefetch
back for types of pages that have refined caching strategy introduced.
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|avif|webp)',
locale: false,
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
}
],
}
];
}
}
Be sure to include newer asset formats like
avif
andwebp
, as these are very useful for squezing out best LCP which often limited by images.
Check out the accompanying demo Repo with added cache configuration for the default Next v15 boilerplate.
There's also an ongoing discussion on the Next.js repo about this.
The reason of such default behaviour is likely caused by the risks of incorrect behavior when assets have actually changed, but CDN would retain the previous version unless you invalidate cache by changing file name, or adding some unique parameter for example:
<img src="image.png?v2">
Stale while revalidate
Note that all examples below are tested against production build, always use it when testing change before deploy, as development mode has overriden cache header values.
The Cache-Control
directive stale-while-revalidate
(SWR) is critical for maximizing cache hit rates, enabling faster delivery of cached content to users. Essentially, SWR extends the cache's lifespan beyond its initial max-age
. It instructs the CDN to serve the cached version while simultaneously re-fetching the content from the origin in the background. For example, with a max-age
of one hour and an SWR of four hours, users will receive cached responses for up to three hours and fifty-nine minutes after the initial expiration.
Without SWR, users would experience a cache miss and fetch fresh content immediately after the max-age
expires. Notably, SWR can be set to extended durations, such as a full day, to further optimize caching. But do be careful for situations where some users will see stale data from time beyond your initial cache TTL.
SWR implementation varies across CDNs, refer do documentation of your provider, to ensure it's actually functioning before relying on it. Last time we checked, Cloudfront and Fastly did support it, while Cloudflare did not, at least on default free plan.
Next.js default use of stale-while-revalidate
Next.js added flexibility and improved standard implementation for SWR header configruation since 13.x, and now such headers are enabled only for ISR pages.
Prior to 15.x, Next.js been adding SWR headers to getStaticProps
pages (pages with default fetch
), but it's removed since, follow the guide below on how to re-introduce this for your setup.
Current Next.js headers on ISR pages with dynamic data (re)fetching:
s-maxage=600, stale-while-revalidate=31535400
Where 600
is the value of export const revalidate
in page component.
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. Default SWR value in Next.js can be adjusted globally in nextConfig
with expireTime
.
For more granular control over SWR and to re-introduce it page other than ISR, read below.
Caching for different Next.js page types and data-fetching strategies
Let's get back to configuring cache settings for Next.js pages, covering different types of page setup options you will likely have combined in your Next.js project.
Since later 14.x releases, Next.js finally allowed overiding cache-control
headers, while prior to that we had to use more workarounds, not it's more straightforward.
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 let's call them "fully static", such does not have any dynamic data at all. It's just a page with the hardcoded data 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 in Pages router with only the Vercel hosting target adding a special config for this once deployed there.
App Router version does have cache-control: s-maxage=31536000
set for these, which is an improvement, but we're still lacking SWR, and your CDN setup might require more explicit public
parameter to consider such pages fully cacheble.
Also note that Next.js defaults to skipping local browser cache, as s-maxage
is the parameter only for CDN layer, while max-age
also instructs user browser to locally cache the asset longer, saving network roundrip and yielding slightly higher bandwidth bills.
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',
}
]
}
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.
Fully dynamic page
Previously known as getServerSideProps
, now in App Router such pages are defined by the way fetch
is configured. As per Next.js doc reference, here's how you transition from Pages router way of thinking to the new data fetching strategy:
// This request should be cached until manually invalidated.
// Similar to `getStaticProps`.
// `force-cache` is the default and can be omitted.
const staticData = await fetch(`https://...`, { cache: 'force-cache' })
// This request should be refetched on every request.
// Similar to `getServerSideProps`.
const dynamicData = await fetch(`https://...`, { cache: 'no-store' })
// This request should be cached with a lifetime of 10 seconds.
// Similar to `getStaticProps` with the `revalidate` option.
const revalidatedData = await fetch(`https://...`, {
next: { revalidate: 10 },
})
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 custom Cache-Control
header value, you'll see following default:
private, no-cache, no-store, max-age=0, must-revalidate
This ensura neither browser, nor CDN caches the page output, 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 adjust Page response headers like this (prev getServerSideProps
):
import { headers } from 'next/headers'
export default async function Page() {
// This makes the page dynamically rendered
const headersList = headers()
// Set cache headers
const res = new Response()
res.headers.set('Cache-Control', 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400, stale-if-error=86400')
return (
<div>
<h1>My SSR Page</h1>
{/* Page content */}
</div>
)
}
So if your data is public, but for some reason you don't want to use static pages, you can still set a good caching strategy.
Default fetch or getStaticProps
Previously known data fetching method getStaticProps
is now used with fetch
with default value cache: 'force-cache'
. These are the pages that can either be pre-rendered during build, or populated dynamically on first visit. The version with revalidate
is known as ISR page, or previously commonly used with getStaticPaths
.
Until 15.x and late 14.x version, this has been the trickiest "page type" to configure, as cache-control
header was not possible to override, now we finally have the full control over headers via next.config.js
. This allows setting variety of cache strategies, referencing page path in the config.
Alternatives to full overrides
It's still worth mentioning, that besides header overrides in config, there are alternative options on how to configure headers for most commonly used page type
- Instead of overriding, you can set extra headers that will be respected by your CDN, like
Surrogate-Control
(Fastly) orCDN-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.
How Self-Hosted NextJS can boost your webiste

Adding headers to static data pages
In next.config.js
, using async headers() { return []}
, you can provide a list of routes matching source
, with your preffered headers.
The headers will be in sync between the page itself (document) and it's RSC paylouad - page path with ?_rsc
param returning text/x-component
with page data for SPA navigations.
{
source: '/appRouter/getStaticProps',
headers: [
{
key: 'Cache-Control',
value: 'public, 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 default value cache-control: s-maxage=31536000
. Example above is better in following ways:
- enables browser, local cache
- adds SWR (stale-while-revalidate), increasing load times of your less frequently visited pages
- adds more explicit
public
property, that might be handy for your CDN setup and make it clear that this page can be agressively cached
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.
Incremental Static Regeneration (ISR)
Static pages with revalidate
are semi-dynamic pages with ability to be agressively cached, but updated periodically, thus labeled as ISR. In pre app
diretory time, it's been getStaticProps
with revalidate
.
It's mostly the same as static pages described in previous section, but have more considerations for periodical cahce update. Also this is the only situation where Next.js applies SWR values. Default headers from Next.js are the following:
s-maxage=600, stale-while-revalidate=31535400
- Where
600
is the inherited fromrevalidate
value, aligning your data lifecycle with cache header - SWR set to a year by default, but can be overriden in next.config with
expireTime
But if you again want to have more control, and browser cache, you will be overriding it. Unfortunately when touching cache-control
header overrides, you'll lose coupling with revalidate
and expireTime
will be ignored, so you'll need to make notes to align cache strategy between page source files and custom overrides in next.config.js
,
You can also consider keeping default Next.js headers, but extending them with some custom instruction for the CDN, setting CDN-Cache-Control
, that will be only respected by the CDN layer.
{
source: '/appRouter/getStaticPaths/:name',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=600, stale-while-revalidate=14400, stale-if-error=14400',
},
],
},
This and all the above config examples in real repo can be found here.
Demos
- Demo repository https://github.com/focusreactive/demo-nextjs-cache-headers-self-host
- Deploy of above repo to Vercel https://demo-nextjs-cache-headers-self-host.vercel.app
- Cloudflare setup in front of the Vercel deployment (note it's not a pure example of self-host, but you can get the idea looking at
Cf-Cache-Status
) https://demo-nextjs-cache-headers-self-host.focusreactive.com - Our team is building a tech talks hub for GitNation with self-host Next.js and Fastly CDN, you can check out the headers setup here https://gitnation.com/ (note that strips out own
Surrogate-Control
from response, but we also overwritecache-control
headers there on CDN level)
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: '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|avif|webp)',
locale: false,
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000',
},
{
key: 'Surrogate-Control',
value: '',
}
]
}
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 Next.js Applications
Fastly employs more aggressive caching rules by default and will cache HTML/JSON files without requiring modifications to Next.js's default header configuration. However, using these default settings means you'll be limited to Fastly's predetermined behaviors and won't have access to important features like stale-while-revalidate
(discussed in detail later).
In the following sections, we'll explore how to exercise more granular control over cache settings by overriding both Next.js and CDN defaults.
Comparing Fastly and Cloudflare
Unlike Cloudflare, Fastly does not offer a free tier. As of this writing, Fastly services begin at a minimum monthly spend of $50. However, our experience shows that Fastly typically delivers faster server response times than Cloudflare and demonstrates significantly less aggressive cache eviction.
No CDN provider guarantees that your assets will remain cached for the entire duration specified in your max-age
or s-maxage
directives. Nevertheless, you would reasonably expect cache retention for multiple hours or days. In our testing, Cloudflare's free tier has been inconsistent in maintaining cache for less frequently accessed URLs. We haven't evaluated how this compares to Cloudflare's paid tiers, though their Cloudflare Cache Reserve service may address these limitations.
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