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.json in the repository.
  • Hashed filenames for CSS and JavaScript (Hugo Pipes fingerprint, Eleventy with a bundler, or the framework's default).
  • curl to 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
PathSiteCache-Control sentx-vercel-cache
/docs/install/Astropublic, max-age=0, must-revalidateHIT
/_astro/index.Ck3d9.cssAstropublic, max-age=31536000, immutableHIT
/Hugopublic, max-age=0, must-revalidateHIT
/css/main.4f8c1b.cssHugopublic, max-age=0, must-revalidateHIT
/images/hero.webpHugopublic, max-age=0, must-revalidateHIT

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.

What Vercel handles automatically and what the site must configure Two panels. Handled by Vercel, the edge caches every file per deployment, invalidates on each new deployment, and framework presets mark known hashed folders immutable. Configured by the site, browser caching for hashed assets outside preset folders, longer caching for unhashed images that rarely change, and headers for any CDN placed in front of Vercel. Edge caching versus browser caching on Vercel Vercel does it edge cache per deployment invalidation on deploy preset asset folders immutable no purge scripts needed The site must do it hashed assets outside presets rarely changing images headers for an outer CDN vercel.json headers
For a Hugo site, everything in the right-hand panel was missing.

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.

How Vercel's three cache headers apply to each cache layer A matrix of three headers against three caches. Cache-Control applies to the browser, to Vercel's edge and to an outer CDN. CDN-Cache-Control applies to Vercel's edge and an outer CDN but not to the browser. Vercel-CDN-Cache-Control applies only to Vercel's edge and is removed before the response leaves Vercel. Which header reaches which cache Header Browser Vercel edge Outer CDN Cache-Control applies applies applies CDN-Cache-Control ignored applies applies Vercel-CDN-Cache- Control stripped applies stripped
Use Cache-Control for browsers, CDN-Cache-Control for any CDN in front, and leave Vercel's own edge to its deployment-based defaults 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)BeforeAfterTool
Requests314WebPageTest
Conditional requests (304)271 (HTML)WebPageTest
LCP1.6 s1.1 sWebPageTest
Start render1.3 s0.8 sWebPageTest
Old HTML visible after deployup to 1 h0Manual check
Number of network requests on a repeat visit before and after the header rules Vertical bars. Before, a repeat visit made 31 requests, 27 of them conditional revalidations. After, it made 4 requests, one of them a conditional revalidation of the HTML. Requests on a repeat visit 31 Before 4 After
WebPageTest repeat view, Moto G Power on 4G, median of nine runs.

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-age on HTML. Vercel's edge already makes HTML fast; browsers should revalidate.
  • immutable on 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.

Filed underHow-tosVercel