Lighthouse kept warning me about inefficient cache lifetimes, even though I had already added caching for my static files.
The missing piece was Nuxt Image and its generated /_ipx URLs.
In this post, I’ll share the simple caching setup I use for Nuxt build files, public assets, and optimized images without risking stale content after deployment.
The basic rule is simple:
Cache files aggressively when changing the file also changes its URL. Be more careful when the same URL can serve different content later.
You have probably seen the same Lighthouse warning I have:
Use efficient cache lifetimes.
Browser caching for static files is usually straightforward. You add a Cache-Control header, choose a reasonable lifetime, and the browser avoids downloading the same files again on every visit.
However, in a Nuxt application, not every static-looking file should use the same caching policy.
Nuxt build files are automatically versioned. Files inside public/ usually are not. Nuxt Image also creates transformed image URLs under /_ipx, which need their own cache rule.
In this post, I’ll go through the setup I use, including the Nuxt Image rule that was missing during my latest Lighthouse audit.
The simple caching rule
The most important question is not whether a file is an image, font, or JavaScript file.
The important question is:
Will the URL change when the file changes?
When the answer is yes, you can safely cache the file for a very long time.
When the answer is no, you should use a shorter cache lifetime. Otherwise, visitors may continue seeing an old version after you deploy an update.
What the cache directives mean
Here are the main directives used in this setup:
-
publicallows browsers and shared caches such as CDNs to store the response. -
max-agecontrols how long the browser considers the file fresh. -
s-maxagecontrols how long shared caches such as Cloudflare consider it fresh. -
immutabletells the browser that the file is not expected to change while that URL exists.
The important one here is immutable.
You should only use it when changing the file also results in a new URL.
The three main asset types in Nuxt
1. Nuxt build files: /_nuxt/**
Nuxt and Vite generate filenames that contain a content hash.
For example:
/_nuxt/entry.Bx3k9Qp2.js
When the file changes, its hash changes too.
That means the URL changes:
/_nuxt/entry.Bx3k9Qp2.js
might become something like:
/_nuxt/entry.Kp8wZ2dA.js
after another deployment.
This makes Nuxt build files perfect candidates for aggressive caching.
You can cache them for a year and use immutable because the next deployment will reference a completely different file when its contents change.
2. Files inside public/
Files in Nuxt's public/ directory work differently.
For example:
public/img/logo.png
is served as:
/img/logo.png
The problem is that its URL does not automatically change when the file changes.
You could replace logo.png during your next deployment while keeping exactly the same URL:
/img/logo.png
If that file was cached for a year with immutable, some users could continue seeing the old logo.
For these files, I normally do one of two things:
- Give them a reasonable cache lifetime, such as several days or weeks.
- Version the filename when I need aggressive caching.
For example:
logo.v2.png
or:
logo.2026-08.png
Once the filename itself changes whenever the content changes, long-term caching becomes much safer.
3. Nuxt Image files: /_ipx/**
This is the part that caused my Lighthouse warning.
When you use Nuxt Image with the local IPX provider, the browser often doesn't request your original image directly.
Instead, Nuxt generates a transformed image URL that can look something like this:
/_ipx/q_80&s_640x360/projects/sharmarket.webp
That URL contains information about the requested transformation, such as image quality and dimensions.
So you may have an original image:
/img/project.webp
while the browser actually loads:
/_ipx/q_80&s_640x360/img/project.webp
Those are two completely different HTTP requests.
And therefore, they can have completely different caching headers.
This was exactly what I had missed.
I already had caching configured for my normal image directory, but Lighthouse was complaining about the generated /_ipx request.
Adding a rule for /img/** does not automatically cache /_ipx/**.
There is another important detail here.
I don't treat IPX URLs as completely immutable by default.
Imagine that this source image changes:
/projects/sharmarket.webp
but you keep the same filename.
The corresponding IPX URL may also remain the same:
/_ipx/q_80&s_640x360/projects/sharmarket.webp
even though the image being returned has changed.
Because of that, I give IPX responses a relatively long cache lifetime, but I don't add immutable.
The Nuxt route rules
Here is the setup I currently use:
export default defineNuxtConfig({
routeRules: {
'/_nuxt/**': {
headers: {
'cache-control':
'public,max-age=31536000,s-maxage=31536000,immutable',
},
},
'/_ipx/**': {
headers: {
'cache-control':
'public,max-age=2592000,s-maxage=2592000',
},
},
'/img/**': {
headers: {
'cache-control':
'public,max-age=864000,s-maxage=864000',
},
},
'/fonts/**': {
headers: {
'cache-control':
'public,max-age=5184000,s-maxage=5184000',
},
},
'/js/**': {
headers: {
'cache-control':
'public,max-age=2592000,s-maxage=2592000',
},
},
},
})
These values are roughly:
-
/_nuxt/**→ one year -
/_ipx/**→ 30 days -
/img/**→ 10 days -
/fonts/**→ 60 days -
/js/**→ 30 days
These are not magic numbers.
They are simply reasonable defaults based on how likely each type of file is to change without its URL changing.
Your application may need different values.
Why the IPX rule matters
This distinction is easy to overlook.
Suppose you have:
/img/project.webp
and Nuxt Image generates:
/_ipx/q_80&s_640x360/img/project.webp
A route rule like:
'/img/**': {
headers: {
'cache-control': 'public,max-age=864000',
},
}
only applies to the first URL.
It does not automatically apply to the IPX response.
Your browser, CDN, Lighthouse, and server all see those as different URLs.
So if Lighthouse complains about an image cache lifetime in a Nuxt application, inspect the actual request URL.
If it begins with:
/_ipx/
then changing your /img/** cache rules isn't going to fix it.
You need a separate /_ipx/** rule.
Do not use immutable everywhere
It can be tempting to do something like this:
Cache-Control: public,max-age=31536000,immutable
for every static asset.
Lighthouse will probably be happier.
Your future self might not be.
Imagine you have:
/img/logo.png
and you cache it for one year with immutable.
One week later, you deploy a new logo under exactly the same URL.
Some browsers may keep using the old file because you explicitly told them that this URL would not change.
A safer strategy is:
- Use very long caching plus
immutablefor hashed files. - Use it for manually versioned files.
- Use shorter caching for URLs that can serve different content later.
- Change the filename when you need reliable cache invalidation.
The goal isn't to give everything a one-year cache because Lighthouse asked nicely.
The goal is to cache every file for as long as its URL structure safely allows.
Cloudflare and CDN caching
There is another layer involved if you're using Cloudflare or another CDN.
Your Nuxt route rules define headers at your application level.
But Cloudflare sits between your application and your users.
Depending on your configuration, Cloudflare may:
- respect your origin caching headers,
- apply its own caching rules,
- decide that a response is not cacheable,
- or override some of your settings.
So after deploying a caching change, don't just assume it worked.
Check the actual response.
For example:
curl -I "https://example.com/_ipx/q_80&s_640x360/projects/sharmarket.webp"
Look for:
cache-control
And if you're using Cloudflare, also look for headers such as:
cf-cache-status
This tells you much more than staring at your nuxt.config.ts and assuming everything between your server and the browser behaves exactly as expected.
Also check your Cloudflare Cache Rules and Workers if the headers you're receiving don't match what Nuxt is sending.
A practical default strategy
If you just want a reasonable starting point, this is how I think about it:
Hashed Nuxt build files
/_nuxt/**
Cache aggressively.
One year plus immutable makes sense because changing the content produces a different filename.
Versioned public files
Files like:
/logo.v4.png
can also be cached aggressively because you'll create a new URL when you replace them.
Normal public assets
Files such as:
/img/logo.png
should generally use shorter cache lifetimes unless you have a versioning strategy.
Several days or weeks is often enough.
Nuxt Image / IPX responses
/_ipx/**
can benefit significantly from caching, but I avoid marking them immutable unless the underlying image paths are also guaranteed to change when the source changes.
How to verify the setup
After deploying your changes:
- Open your browser's developer tools.
- Go to the Network tab.
- Inspect one file under
/_nuxt. - Inspect an original image from
/img. - Inspect an optimized Nuxt Image request under
/_ipx. - Check the
Cache-Controlresponse header for each one. - If you're using Cloudflare, inspect
cf-cache-status. - Run Lighthouse again.
This is especially useful because the URL Lighthouse reports might not be the URL you expected.
That was the mistake in my case.
I was looking at my original images while Lighthouse was actually complaining about their transformed IPX versions.
FAQ
Is static file caching necessary on Vercel or similar platforms?
Yes, but you may not need to configure every part of it yourself.
Platforms like Vercel already handle many framework-generated assets efficiently, especially hashed build files.
That doesn't mean every URL in your application automatically receives the exact cache policy you want.
Things like public files, custom server routes, and generated image URLs such as /_ipx can still be worth checking.
The important thing is to inspect the actual response headers instead of assuming the hosting platform has already handled everything.
Shouldn't Nginx handle this?
It can.
If Nginx sits in front of your Nuxt application, you can absolutely configure caching headers there.
For example, you could define different policies for /_nuxt, /img, fonts, scripts, and other paths.
I personally prefer putting these rules inside Nuxt when possible because the caching policy then lives with the application.
If I move the application to another server or deployment platform, I don't have to remember that an important part of its behavior was hidden inside an Nginx configuration somewhere else.
But both approaches are valid.
I deploy with Coolify. Doesn't Coolify have options for this?
Coolify handles deployment and usually places a reverse proxy such as Traefik in front of your application.
That infrastructure can be configured to manipulate response headers, but it doesn't automatically know what caching policy makes sense for your application.
For example, it cannot magically know that your /_nuxt files are hashed while /img/logo.png may be replaced later under the same URL.
That's application-specific knowledge.
For me, defining the policy in Nuxt is simpler and makes the application more portable.
What if I want a specific file not to be cached?
You can create a more specific route rule.
For example:
routeRules: {
'/img/**': {
headers: {
'cache-control': 'public,max-age=864000',
},
},
'/img/dynamic-image.png': {
headers: {
'cache-control': 'no-store',
},
},
}
The specific route can override the broader one.
Use no-store when you genuinely don't want the response stored.
If you still want caching but want clients to revalidate the resource before using it, no-cache has a different meaning and may be more appropriate.
How can I purge a cached file when necessary?
This depends on where it has been cached.
If a CDN such as Cloudflare has the file, you can purge it from the CDN.
If you have another reverse proxy or server-side cache, that may also have its own invalidation mechanism.
The tricky part is the browser cache.
You generally cannot remotely force every user's browser to delete a file it has already cached.
That's why versioned URLs are so useful.
Instead of trying to make users forget:
/logo.png
you deploy:
/logo.v2.png
Now the browser sees a completely new URL and requests it normally.
For important static assets, URL versioning is often a much cleaner invalidation strategy than relying on cache purges.
Can I purge the Cloudflare cache?
Yes.
Cloudflare lets you purge cached resources from its dashboard, and it also exposes cache-purge APIs.
When possible, I prefer purging only the specific URL that changed rather than clearing the entire cache.
But remember: purging Cloudflare removes Cloudflare's cached copy.
It does not delete a file that a user's browser has already cached according to a long max-age or immutable policy.
Again, when immediate invalidation matters, changing the URL is usually the safest option.
Conclusion
Static file caching becomes much simpler once you stop treating every static-looking file the same way.
Nuxt's hashed build files can be cached for a very long time because every content change creates a new URL.
Files inside public/ require more care because their URL normally stays the same even if you replace the file.
Nuxt Image adds another layer because the optimized files requested by the browser live under /_ipx/**, which needs its own caching policy.
That last part was what I had missed in my own setup.
Once I separated those asset types and started thinking about caching based on whether the URL changes with the content, the whole thing became much easier to reason about.
And Lighthouse stopped complaining for the right reason — not because I blindly made every file immutable, but because the files were actually being cached according to how they behave.
Originally published on sadeq.dev.
Top comments (0)