
Configuring CI build caching
If you've ever watched a Next.js build run in CI and wondered why it takes three minutes on your laptop but twelve minutes on GitHub Actions, the answer is almost always the same: your local machine has a warm .next/cache directory, and your CI runner starts from nothing every single time. Next.js relies heavily on incremental compilation — it remembers what it compiled last time and only redoes the parts that changed. Strip that memory away, and every build becomes a full rebuild, no matter how small your change was.
This is one of those problems that's invisible until it isn't. A small project won't notice. But once you're running a monorepo with a few dozen routes, dozens of API endpoints, and a CI pipeline that runs on every push and every pull request, build time turns into a genuine cost — in developer wait time, in CI minutes billed by the provider, and in how quickly you can ship a hotfix. Configuring your CI to persist .next/cache between runs is one of the highest-leverage, lowest-effort changes you can make to a Next.js project's development velocity, and it's astonishing how often it's skipped entirely.
This article walks through what's actually inside that cache, why CI environments lose it by default, and exactly how to wire up persistence for the CI providers you're most likely to be using — GitHub Actions, GitLab CI, CircleCI, and half a dozen others — along with the mistakes that quietly break caching even after you think you've set it up correctly.
Why Next.js Needs a Persistent Cache in the First Place
When you run next build, Next.js doesn't just compile your code once and throw away its intermediate state. It writes a substantial amount of information to .next/cache:
- Webpack/Turbopack build cache — compiled modules, source maps, and dependency graphs that let the bundler skip re-parsing and re-transforming files that haven't changed.
- The Next.js data cache — if you're using
fetchcaching orunstable_cachein your data layer, some of that cache metadata is persisted here too, depending on your cache handler configuration. - Image optimization cache — resized and re-encoded images that
next/imagehas already processed, so subsequent builds and requests don't have to redo that work. - ISR page cache entries — for statically generated pages using Incremental Static Regeneration, cached HTML and JSON payloads.
On your own machine, this directory just sits there between builds, quietly making every subsequent next build or next dev faster. The moment you move that build into a CI environment — a fresh container spun up for every single job — that directory doesn't exist. It gets created, populated, and then thrown away the instant the job finishes, along with the container itself.
This is fundamentally different from most caching problems developers deal with. You're not choosing whether to cache; Next.js already does the caching for you automatically. The only question is whether your CI configuration lets that cache survive from one run to the next. If it doesn't, you're not just losing a "nice to have" — you're forcing a completely cold build every time, indistinguishable (from a performance perspective) from wiping node_modules and .next and starting completely fresh on every single commit.
How to Tell If You're Missing This
Before diving into provider-specific configuration, it's worth confirming you actually have the problem. The most direct signal is a warning Next.js prints during the build itself:
⚠ No build cache found. Please configure build caching for faster rebuilds. Read more: https://nextjs.org/docs/messages/no-cache
If you see this in your CI logs on every single run — not just the first one — that's your confirmation. A properly configured cache should show up as present starting from your second CI run onward. If it still says "No build cache found" after multiple runs against the same branch, your persistence configuration isn't actually working, even if you think you wired it up.
A less obvious but equally useful signal: compare wall-clock build times between a local next build on a warm cache and the same build running in CI. If CI consistently takes several times longer for changes that only touch a handful of files, that gap is your caching tax.
The General Pattern Across All CI Providers
Every CI provider solves this the same conceptual way, even though the configuration syntax differs wildly: you tell the CI system to save specific directories after a job finishes, and restore them before the next job starts, keyed by something that identifies when the cache should be considered valid or stale.
The two directories you almost always want cached are:
node_modules(or your package manager's own cache, like~/.npmor the Yarn/pnpm store) — so dependency installation doesn't redownload everything from the registry every time..next/cache— so the Next.js build itself can reuse prior compilation work.
Notice what's conspicuously absent from that list: .next itself (without /cache). You do not want to cache the entire .next directory, only the .next/cache subdirectory. The rest of .next contains your actual build output — the compiled pages, the server bundle, static assets with content hashes in their filenames — and that output needs to be regenerated fresh from your current source code every time. Caching the full .next directory risks serving stale build artifacts that don't match your latest commit, which is a much worse problem than a slow build.
Vercel: The Zero-Configuration Baseline
If you deploy through Vercel, this entire article is largely moot for your production deployments — Vercel automatically manages this caching for you as part of its build pipeline, and there's no configuration required on your end. This is worth calling out explicitly because it's easy to assume every deployment target needs manual cache wiring, when in practice the platform Next.js's own maintainers run handles it invisibly.
Where this still matters even if you deploy to Vercel: your separate CI pipeline (GitHub Actions running your test suite, type-checking, and linting before Vercel even sees the commit) is a different execution environment entirely and does need its own caching configuration if it also runs next build as part of validating a pull request. If you're using Turborepo on top of Vercel, there's an additional layer of remote caching worth reading up on separately, since it caches at the task level across your whole monorepo rather than just within a single Next.js app.
GitHub Actions
This is the one most readers of this blog will actually need, so it's worth spending the most time on. GitHub Actions uses the actions/cache action, and the configuration looks like this:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Restore Next.js build cache
uses: actions/cache@v4
with:
path: |
~/.npm
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: npm ci
- run: npm run build
The key and restore-keys fields deserve a closer look, because they're the part people misconfigure most often. actions/cache works on exact key matches — if the computed key doesn't match anything previously saved, it falls through to restore-keys, which acts as a prefix match against the most recent cache that starts with that string.
Here, the primary key hashes both your lockfile and your actual source files. That means:
- If neither your dependencies nor your source code changed, you get an exact cache hit — the fastest possible outcome.
- If your source code changed but your dependencies didn't, the exact key won't match (because the source-file hash changed), but the
restore-keysprefix (which only depends on the lockfile hash) still matches a recent cache. You get a partial cache — not a perfect match, but Next.js's incremental compiler can still reuse most of the previous work and only recompile what changed. - If your dependencies changed, neither the exact key nor the restore-keys prefix will match anything valid, and you fall back to a genuinely fresh cache.
This tiered strategy is deliberate and worth replicating even if you write your own key scheme by hand: hash on dependencies first, source files second, so a dependency bump doesn't accidentally serve a cache built against an incompatible module graph.
One GitHub Actions-specific gotcha: caches are scoped per-branch by default in terms of restore priority, but a cache created on your default branch is visible as a fallback to caches on feature branches, while the reverse isn't true. This means your main branch builds effectively "seed" the cache that pull request builds from feature branches can restore from, which is exactly the behavior you want — new branches get a running start instead of a cold cache.
GitLab CI
GitLab CI's .gitlab-ci.yml handles this more simply, using its own built-in cache keying:
build:
stage: build
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .next/cache/
script:
- npm ci
- npm run build
CI_COMMIT_REF_SLUG keys the cache per-branch, which is a reasonable default — each branch accumulates its own cache over successive commits. If you want cross-branch cache sharing similar to the GitHub Actions restore-keys behavior, you can key on something more static, but be aware that a shared cache key across all branches means a dependency change on one branch can pollute the cache for every other branch reading from that same key. GitLab does support fallback cache keys for more nuanced setups, worth checking their docs on cache:key:files if this becomes a bottleneck for you.
CircleCI
CircleCI separates the "save" and "restore" steps explicitly rather than handling both in a single directive:
version: 2.1
jobs:
build:
docker:
- image: cimg/node:20.11
steps:
- checkout
- restore_cache:
keys:
- dependency-cache-{{ checksum "yarn.lock" }}
- run: yarn install
- run: yarn build
- save_cache:
key: dependency-cache-{{ checksum "yarn.lock" }}
paths:
- ./node_modules
- ./.next/cache
The pattern is the same as everywhere else — checksum a lockfile, use it as the cache key — but CircleCI makes the restore/save split visible in your pipeline, which is genuinely useful for debugging. If a build isn't picking up the cache you expect, you can look directly at whether restore_cache reports a hit or a miss in the job output, rather than inferring it indirectly from build time.
Travis CI, AWS CodeBuild, Bitbucket Pipelines, and Azure Pipelines
These providers all follow the same shape with different YAML dialects, so rather than repeat the explanation four times, here's each configuration on its own:
Travis CI (.travis.yml):
cache:
directories:
- $HOME/.cache/yarn
- node_modules
- .next/cache
AWS CodeBuild (buildspec.yml):
cache:
paths:
- "node_modules/**/*"
- ".next/cache/**/*"
Bitbucket Pipelines (bitbucket-pipelines.yml):
definitions:
caches:
nextcache: .next/cache
pipelines:
default:
- step:
name: Build
caches:
- node
- nextcache
script:
- npm ci
- npm run build
Azure Pipelines (in your pipeline YAML, placed before the step that runs next build):
- task: Cache@2
displayName: "Cache .next/cache"
inputs:
key: 'next | "$(Agent.OS)" | yarn.lock'
path: "$(System.DefaultWorkingDirectory)/.next/cache"
Azure's Cache@2 task is worth a specific note: unlike most other providers, it caches and restores a single path per task instance, so if you also want to cache your package manager's global store, you'll add a second Cache@2 task with its own key rather than listing multiple paths under one task.
Netlify
Netlify handles this differently from the rest of this list, because it doesn't ask you to write cache configuration by hand at all. Instead, you install the official adapter plugin:
npm install -D @netlify/plugin-nextjs
And Netlify's build system manages caching (along with quite a bit of other Next.js-specific behavior, like ISR and image optimization routing) as part of what that plugin wires up automatically. If you're deploying to Netlify, don't try to replicate the manual .next/cache path configuration from the other providers — let the plugin do it, since it understands Next.js's cache internals more precisely than a generic "cache this directory" instruction would.
Heroku
Heroku's caching model works through your package.json rather than a separate CI config file:
{
"cacheDirectories": [".next/cache"]
}
Heroku automatically caches whatever paths you list here between deploys (technically, between git push heroku main invocations that trigger a new build on their buildpack infrastructure). There's no separate restore step to configure — it's handled as part of the buildpack's own lifecycle.
Jenkins
Jenkins is the outlier in this list because it doesn't have first-party cache primitives baked into its core the way the hosted CI providers do — you reach for the community Job Cacher plugin instead, and the configuration lives directly in your Jenkinsfile:
stage("Restore npm packages") {
steps {
writeFile file: "next-lock.cache", text: "$GIT_COMMIT"
cache(caches: [
arbitraryFileCache(
path: "node_modules",
includes: "**/*",
cacheValidityDecidingFile: "package-lock.json"
)
]) {
sh "npm install"
}
}
}
stage("Build") {
steps {
writeFile file: "next-lock.cache", text: "$GIT_COMMIT"
cache(caches: [
arbitraryFileCache(
path: ".next/cache",
includes: "**/*",
cacheValidityDecidingFile: "next-lock.cache"
)
]) {
sh "npm run build"
}
}
}
The cacheValidityDecidingFile parameter is doing the same conceptual job as a hash-based cache key elsewhere: the plugin recomputes a checksum of that file and only considers the cache valid if it matches what was recorded when the cache was saved. Writing the git commit hash into a throwaway file before each stage, as shown above, is a common pattern for forcing cache invalidation tied to a specific commit when you don't have a cleaner hash source available.
Self-Hosted Runners: Skipping the Cache Action Entirely
Everything above assumes you're using ephemeral, hosted CI runners — a fresh container or VM spun up for every job and destroyed afterward. That's the default for GitHub-hosted GitHub Actions runners, GitLab's SaaS runners, and most cloud CI offerings, which is exactly why they all need an explicit cache save/restore step.
If you run your own self-hosted runners instead — a persistent EC2 instance, a dedicated build machine in your office, or a long-lived Kubernetes pod acting as a runner — the calculus changes. A self-hosted runner's filesystem survives between jobs by default, which means .next/cache can simply sit on disk untouched, with no actions/cache step or equivalent required at all. Your build just runs next build against whatever was left there last time, the same way it would on your laptop.
This isn't free of trade-offs, though:
- Concurrent jobs on the same runner can race on the same cache directory. If two builds for two different branches run on the same self-hosted machine at overlapping times, they can stomp on each other's
.next/cachestate. Hosted-runner cache actions avoid this because each job gets an isolated filesystem and a keyed, versioned cache blob rather than a shared mutable directory. If you go the self-hosted route, either pin builds to run serially, or give each concurrent job its own working directory (and thus its own cache) rather than sharing one checkout across jobs. - Nothing enforces cache invalidation for you. A hosted cache action ties a fresh cache to a new key automatically when your lockfile changes. On a self-hosted runner with a persistent disk, a stale cache just sits there indefinitely unless you build in your own cleanup logic — for instance, wiping
.next/cachein your pipeline whenever the lockfile hash on disk doesn't match the one from the last successful build. - Disk space becomes an operational concern you own. Hosted providers evict old caches for you once you hit a quota. A self-hosted runner will happily let
.next/cache(and every other build artifact you've ever left lying around) grow until the disk fills up and jobs start failing for an unrelated, confusing reason.
If you're already running self-hosted runners for other reasons (cost, needing access to an internal network, GPU builds, whatever it may be), this is a legitimate and often simpler way to get Next.js build caching — just budget for the maintenance it quietly asks of you in return.
Practical Notes the Docs Don't Spell Out
Your very first CI run after adopting this will still be slow, and that's expected. There's no cache to restore yet on the first run — you're populating it for the first time. Don't judge whether your configuration worked based on that first build; judge it based on the second one, once there's something to restore.
Parallel CI jobs building the same branch can race on cache writes. If your pipeline fans out into multiple parallel jobs that each independently run next build against the same branch (for example, a matrix build testing multiple Node versions), each job may try to save a cache under the same key when it finishes. Most hosted providers handle this gracefully by keeping the first successful save and ignoring subsequent ones for that exact key, but it's worth confirming your provider's specific behavior rather than assuming, especially if you notice one matrix leg's cache silently "winning" over another's in a way that surprises you.
Caching .next/cache, not .next, is not optional. I called this out earlier, but it bears repeating because it's the single most common mistake I've seen in real pipelines. If you accidentally cache the whole .next directory, you risk your CI serving a build output directory that's a patchwork of old and new files — old static chunks sitting alongside new server code, referencing hashes that no longer match. Next.js's incremental build system is specifically designed to safely reuse .next/cache; it is not designed to have arbitrary chunks of .next itself restored from an unrelated commit.
A stale cache is a correctness risk, not just a performance one, under one specific circumstance. If you ever change a dependency in a way that alters build output shape (a major version bump of a bundler plugin, for instance) without also bumping your lockfile hash for some reason (this can happen with certain private registry configurations or lockfile-less installs), you can end up with a cache that's technically "valid" by your key scheme but produces subtly broken output. This is rare, but if you ever see a CI build succeed while producing genuinely broken behavior that a clean build doesn't reproduce, wiping the CI cache manually is a legitimate first debugging step, not just a superstition.
Monorepos need scoped cache keys, not just scoped directories. If you're running Next.js inside a Turborepo or Nx monorepo with multiple apps, a single shared .next/cache cache key across all of them will cause cross-contamination — one app's build cache polluting another's. Scope your cache key to include the app's path or package name, not just the top-level lockfile hash, so each app gets an independent cache lineage.
Cache size grows over time, and most providers cap it. GitHub Actions, for instance, has a per-repository cache size limit (10GB total as of this writing, with least-recently-used eviction once you exceed it). A .next/cache directory for a large app with many routes and a lot of image optimization activity can genuinely balloon. If your caches keep evicting sooner than you'd expect, check whether other jobs in the same repo are competing for that same cache budget.
Docker-based CI builds need caching wired at the image-build layer, not just the CI-runner layer. If your CI pipeline builds a Docker image that runs next build inside the Dockerfile itself (a very common self-hosting pattern), none of the configuration above helps you directly — you're now caching Docker layers instead, using --cache-from/--cache-to with BuildKit, or a multi-stage build that copies a previously-built .next/cache into the image before running the build step. This is a meaningfully different problem from persisting cache directories at the CI-job level, and it's easy to set up the wrong one and wonder why builds are still slow.
Key Takeaways
| Scenario | What to cache | Key strategy |
|---|---|---|
| Vercel-hosted production deploys | Nothing — handled automatically | N/A |
| GitHub Actions | ~/.npm (or equivalent) + .next/cache | Hash lockfile + source files, with a lockfile-only restore-key fallback |
| GitLab CI | node_modules/ + .next/cache/ | CI_COMMIT_REF_SLUG (per-branch) by default |
| CircleCI | ./node_modules + ./.next/cache | Explicit restore_cache/save_cache keyed on lockfile checksum |
| Netlify | Handled by @netlify/plugin-nextjs | N/A |
| Monorepo (Turborepo/Nx) | Same paths, scoped per app | Include the app/package path in the cache key |
| Docker-based self-hosted builds | Docker layer cache, not just .next/cache | BuildKit --cache-from/--cache-to or multi-stage COPY |
Persisting .next/cache is a small amount of YAML for most providers, and it's one of the rare optimizations that costs you nothing to adopt and only ever pays off. The failure mode when you skip it isn't dramatic — nothing breaks, no error blocks your deploy — it just quietly taxes every single CI run for the lifetime of the project. Given how cheap it is to configure once, there's very little reason not to check this box the same day you set up your CI pipeline in the first place.


