Cache-Control Headers on Vercel for Static Sites
Vercel takes much of the caching work off a static site: its edge caches every file from a deployment and serves the new deployment's files the moment it goes live, so HTML can be cached without purge scripts. What it does for browsers depends on what it recognises. For a Next.js or Astro site, framework presets mark the hashed asset folders as immutable. For a Hugo or Eleventy site, or anything with assets in a folder the preset does not know, every asset goes out with max-age=0, must-revalidate, and returning readers revalidate every file on every page.
This guide audits the headers on two static sites deployed to Vercel — an Astro docs site and a Hugo marketing site — fixes the Hugo site's asset caching in vercel.json, and measures repeat-visit performance before and after. It belongs to CDN Caching Rules for SSGs; the Vercel build configuration is in Configuring Vercel for Hugo and Eleventy.
Prerequisites
- A static site deployed to Vercel, with
vercel.jsonin the repository. - Hashed filenames for CSS and JavaScript (Hugo Pipes
fingerprint, Eleventy with a bundler, or the framework's default). curlto read headers.
Step 1: Audit What Vercel Sends
for u in / /docs/install/ /_astro/index.Ck3d9.css /css/main.4f8c1b.css /images/hero.webp; do
printf '%-28s ' "$u"; curl -sI "https://example.vercel.app$u" | grep -iE '^(cache-control|x-vercel-cache|age):' | tr -s '\r\n' ' '; echo
done
| Path | Site | Cache-Control sent | x-vercel-cache |
|---|---|---|---|
/docs/install/ | Astro | public, max-age=0, must-revalidate | HIT |
/_astro/index.Ck3d9.css | Astro | public, max-age=31536000, immutable | HIT |
/ | Hugo | public, max-age=0, must-revalidate | HIT |
/css/main.4f8c1b.css | Hugo | public, max-age=0, must-revalidate | HIT |
/images/hero.webp | Hugo | public, max-age=0, must-revalidate | HIT |
Everything was cached at Vercel's edge (HIT). The difference was in browsers: the Astro preset knew /_astro/ is hashed; the Hugo site's fingerprinted CSS and processed images were sent with max-age=0, so each repeat visit made a conditional request per file.
Step 2: Add Header Rules for Hashed Assets
Hugo writes fingerprinted CSS and JS to /css/ and /js/, and processed images (whose names include a hash) to their page bundles. Rules in vercel.json match them:
{
"headers": [
{
"source": "/(css|js)/(.*)\\.([0-9a-f]{8,})\\.(css|js)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
},
{
"source": "/(.*)_hu([0-9a-f]+)_(.*)\\.(webp|avif|jpg|png)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
},
{
"source": "/fonts/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
}
]
}
The second pattern matches Hugo's processed image names, which contain _hu followed by a hash. Fonts are versioned by folder name on this site (/fonts/inter-4.0/), so they get the same treatment. Rules use path-to-regexp syntax; testing each against real filenames with vercel dev or a preview deploy before merging avoids rules that match nothing.
Step 3: Leave HTML Alone (Mostly)
Vercel's default for HTML — max-age=0, must-revalidate for browsers, cached at the edge until the next deployment — is the right policy for a static site. There is nothing to add unless another CDN sits in front of Vercel. The Hugo site's marketing team had added a rule giving HTML max-age=3600 "for speed"; it was removed, because it meant browsers kept old pages for an hour after each deploy, and Vercel's edge already made HTML fast.
Step 4: A CDN in Front of Vercel
The docs site was also served through a corporate CDN for one region. That CDN cannot see Vercel deployments, so it needs a TTL and a purge. CDN-Cache-Control sets caching for CDNs without changing what browsers get:
{
"source": "/((?!_astro/).*)",
"headers": [
{ "key": "CDN-Cache-Control", "value": "max-age=300, stale-while-revalidate=600" }
]
}
The outer CDN caches HTML for five minutes and serves stale for up to ten while revalidating; browsers still revalidate every time. Vercel-CDN-Cache-Control would apply only to Vercel's own edge and is not needed for static files.
Step 5: Verify in CI
The audit script from Step 1 runs against each preview deployment, asserting immutable on a list of known hashed paths taken from the build output and max-age=0 on HTML. It caught one regression in its first month: a Hugo upgrade changed the image processing name pattern from _hu followed by a hash to a slightly different form, and the image rule silently stopped matching.
Eleventy and Other Generators Without a Preset
Eleventy has no Vercel framework preset that knows its asset layout, because Eleventy has no fixed asset folder. The rule depends on how the site produces hashed files. A common setup bundles CSS and JS with esbuild into /assets/ with [name].[hash] names; one header rule matching /assets/(.*)\\.([0-9a-f]{8,})\\.(css|js) covers it. Images processed by @11ty/eleventy-img get hashed filenames in /img/ by default, which a second rule covers. Anything copied through verbatim with addPassthroughCopy keeps its original name and should not get immutable; a day of caching (max-age=86400) is a safe default for logos and icons that rarely change.
The same audit applies to any generator: list the build output's files, separate those whose names change when their content changes from those that do not, and write one rule per group. A short script that lists output files without a hash in the name but with a long max-age in the deployed headers found two mistakes on the Hugo site — a site.webmanifest and an og-default.png — that would otherwise have stayed stale for a year after the next brand refresh.
Preview Deployments and Caching
Preview deployments on Vercel get the same headers as production, which is what you want for testing them. One side effect surprised reviewers: after opening a preview, then a newer preview of the same branch, the browser showed the old CSS for hashed files only if the hash had not changed — which is correct — but assets with fixed names and a long max-age stayed stale between previews. Keeping long caching strictly to hashed names, as above, avoids that confusion as well.
Measured Impact
Hugo marketing site, repeat visit to a second page after one day, WebPageTest on a mid-range phone:
| Measure (repeat view) | Before | After | Tool |
|---|---|---|---|
| Requests | 31 | 4 | WebPageTest |
| Conditional requests (304) | 27 | 1 (HTML) | WebPageTest |
| LCP | 1.6 s | 1.1 s | WebPageTest |
| Start render | 1.3 s | 0.8 s | WebPageTest |
| Old HTML visible after deploy | up to 1 h | 0 | Manual check |
Each 304 is small, but on a phone each also costs a round trip, and 27 of them delayed rendering by about half a second.
Pitfalls & Rollback
- Assuming the preset covers every hashed file. It covers the framework's own folder only.
- Long
max-ageon HTML. Vercel's edge already makes HTML fast; browsers should revalidate. immutableon unhashed files. A changed logo at the same URL will never update for returning readers.- Rules that match nothing. Test patterns against real filenames on every generator upgrade.
- Rollback: header rules live in
vercel.json; removing them reverts on the next deployment.
Conclusion
On Vercel, the edge cache takes care of itself, and the work is browser caching for files the framework preset does not recognise. For a Hugo site that meant three header rules in vercel.json, which cut a repeat visit from 31 requests to 4 and LCP from 1.6 to 1.1 seconds, while HTML kept Vercel's revalidate-every-time default.
FAQ
How does Vercel cache static files by default?
Vercel's edge network caches static output and invalidates it automatically on every deployment, so HTML can be cached at the edge without manual purges. Browsers receive Cache-Control public, max-age 0, must-revalidate for files without a framework-specific rule, which makes them revalidate on each visit. Framework presets add long-lived immutable headers for known hashed asset folders.
Do I need to set Cache-Control on Vercel at all?
For HTML, usually not; the defaults are correct. For hashed assets outside the folders a framework preset knows about, such as a custom assets directory or a generator Vercel does not detect, yes. Without a rule those assets revalidate on every visit, which costs a round trip per file.
What is CDN-Cache-Control on Vercel?
A header that controls caching in CDNs separately from browsers. Vercel-CDN-Cache-Control applies only to Vercel's edge, CDN-Cache-Control to Vercel and any other CDN in front, and Cache-Control to everything including browsers. Vercel strips its own CDN header before sending the response, so browsers see only Cache-Control.
Does a custom Cache-Control in vercel.json break automatic invalidation?
No. Vercel's edge cache for static files is tied to the deployment, so a new deployment serves new files regardless of the headers on the old ones. The headers mainly affect browsers and any CDN you add in front of Vercel, which do not know about Vercel deployments.
Related
- Parent: CDN Caching Rules for SSGs — caching policy across hosts.
- Configuring Vercel for Hugo and Eleventy — building these generators on Vercel.
- Proper Cache Headers on Netlify for SSGs — the equivalent on Netlify.
- Security Headers on Vercel for Static Sites — the other headers in
vercel.json. - Cache Busting with Content-Hashed Filenames — what makes
immutablesafe.