How I Cut My Next.js CI Build Time With GitHub Actions Caching
Cache the npm download cache and .next/cache in GitHub Actions with the right keys, so every push stops running a cold Next.js build.
Admin
7 min read·
For a long time my Next.js CI pipeline on GitHub Actions did the same thing on every single push: install every dependency from scratch, then rebuild the whole app from zero. Nothing was broken, it was just slow, and slow CI quietly changes how you work. You stop pushing small commits. You batch changes. You merge with less feedback.
The fix turned out to be two small caching steps. In this post I'll walk through what is actually worth caching in a Next.js project, how I wire it up in GitHub Actions, and the mistakes that make a cache look configured while it never really hits.
The symptom: "No build cache found"
If you run next build in CI without persisting anything between runs, Next.js tells you. The build output includes a warning that no build cache was found, and the Next.js docs have a dedicated page for it (the "No Cache Detected" message). That warning is the clearest hint you'll get: every CI build is a cold build.
Here's the kind of workflow most of us start with:
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm run build
It works, but every run downloads the full npm tarball set and throws away all of Next.js's compilation work. Two different things are being wasted there, and they need two different caches.
What is actually worth caching
1. The npm download cache (~/.npm)
npm ci always deletes node_modules and reinstalls from the lockfile. That's what you want in CI, because it guarantees a clean, reproducible install. But the expensive part is usually downloading packages, not unpacking them. npm keeps a content-addressed cache in ~/.npm, and if that directory is already populated, npm ci can install from it instead of the network.
So I cache ~/.npm, not node_modules. Caching node_modules directly is tempting, but npm ci wipes it anyway, and it can bake platform-specific native binaries into a cache that gets restored somewhere it shouldn't.
2. The Next.js build cache (.next/cache)
Next.js writes a cache to .next/cache that is meant to be shared between builds. It holds compiler cache data and other artifacts that let the next build skip work it already did. On a developer machine it just sits there and helps. On a fresh CI runner it's gone every time, unless you explicitly save and restore it.
The workflow I use now
Here's the full job. The versions are the current major releases of each official action as I write this; check each action's README before you copy it, because they move fast.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm # caches ~/.npm, keyed on package-lock.json
- name: Restore Next.js build cache
uses: actions/cache@v6
with:
path: ${{ 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 lint
- run: npm run build
Two caches, two mechanisms:
- actions/setup-node with cache: npm handles the npm download cache for you. It uses actions/cache under the hood and keys the cache on your lockfile. Recent major versions of setup-node can even enable npm caching automatically based on your package.json, but I set it explicitly so the workflow says what it does.
- actions/cache handles .next/cache. This is the same pattern the Next.js docs recommend for GitHub Actions; the only difference is I let setup-node own ~/.npm instead of adding it to this path list.
Order matters: the restore step must run before npm run build. actions/cache restores when the step runs and saves automatically in a post-job step if there was no exact key hit, so the build in between is what fills the cache.
Understanding the cache key
The key is where most caching setups quietly go wrong, so it's worth slowing down here. GitHub Actions caches are immutable: once an entry is saved under a key, it is never overwritten. If your key never changes, you save one cache on the first run and restore that stale snapshot forever.
The key above is built from two hashes:
# exact key: lockfile hash + source hash
Linux-nextjs-3f9a...-81bc...
# restore-keys prefix: lockfile hash only
Linux-nextjs-3f9a...-
- The exact key includes a hash of the lockfile and a hash of your source files. Any code change produces a new key, so a fresh cache is saved after each build with new code.
- The restore key is only the prefix with the lockfile hash. When there's no exact match (which is most runs, because you changed code), Actions restores the most recent cache whose key starts with that prefix. Next.js then reuses whatever is still valid and rebuilds only what changed.
Leaving the source hash out of the prefix is deliberate. If dependencies change, the lockfile hash changes, nothing matches the prefix, and you start clean. That's what you want: a build cache from a different dependency tree is more likely to cause confusion than to save time.
A cache key should answer one question: "when is the old cache no longer safe to start from?" For a Next.js build cache, the honest answer is "when the dependencies changed".
Mistakes I made (so you don't have to)
Caching with a static key
My first attempt used key: nextjs-cache. The first run saved it, and every later run restored that same original snapshot, because an existing key is never overwritten. The cache hit rate looked perfect and the builds barely got faster. If your cache-hit output is true on every run while your code is changing, your key is too coarse.
Restoring after the build
If the cache step sits after npm run build, the build runs cold and the warning stays. Restore first, build second.
Wrong path in a monorepo
In a monorepo the cache lives next to the app, not at the repository root. If your Next.js app is in apps/web, point both caches there:
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: apps/web/package-lock.json
- uses: actions/cache@v6
with:
path: ${{ github.workspace }}/apps/web/.next/cache
key: ${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}-${{ hashFiles('apps/web/**/*.ts', 'apps/web/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-web-${{ hashFiles('apps/web/package-lock.json') }}-
setup-node's cache-dependency-path input tells it which lockfile to hash, and scoping the source hash to the app means changes in an unrelated package don't invalidate the web app's key.
Forgetting that caches are scoped by branch
GitHub Actions restricts which caches a workflow run can restore: a run can use caches created on its own branch and on the base branch, but not caches from sibling feature branches. In practice that means your main branch needs to build regularly so pull requests have a warm cache to restore from. That's one reason my workflow runs on pushes to main, not only on pull requests.
Ignoring the size limit
Repositories have a total cache storage limit, and GitHub evicts older entries when you go over it. Because the exact key changes on every code change, a busy repo saves a lot of entries. That's normal and eviction handles it, but it's worth checking the Caches page under your repository's Actions tab once in a while to see what's being stored and how large .next/cache has grown.
How to tell it's working
I don't trust caching until I've seen it work, so I check three things after the first couple of runs:
- The cache step log. On a second run you should see a line saying the cache was restored from a key. If it says no cache was found, compare the keys being saved and requested.
- The build output. The "no build cache found" warning from next build should disappear.
- Step timings. Compare the duration of the install and build steps on a warm run against a cold one. That number, not a feeling, tells you whether the setup earns its YAML.
How much you save depends heavily on project size, dependency count and how much code changed, so I won't quote a universal number. What I can say is that you'll know within two runs whether it helps your project, because the step timings are right there in the Actions UI.
Where this fits in a bigger pipeline
Once caching is in place, the same idea extends naturally. If you run tests and the build in separate jobs, each job needs its own restore step, since jobs run on separate runners. If you deploy with Vercel, its build infrastructure handles the Next.js build cache for you, so this setup matters most for GitHub Actions checks, self-hosted deployments and Docker-based pipelines. And if you add tools with their own caches, like Playwright browsers or a Turborepo cache, the same rules apply: cache the expensive, reproducible thing, and key it on what makes it invalid.
Key takeaways
- A Next.js CI build has two separate things to cache: the npm download cache (~/.npm) and the Next.js build cache (.next/cache).
- Cache ~/.npm, not node_modules; npm ci deletes node_modules on every install anyway.
- Let actions/setup-node handle npm caching and use actions/cache for .next/cache, restoring it before next build.
- Build the key from the lockfile hash plus a source hash, and use the lockfile-only prefix as a restore-keys fallback. Cache entries are never overwritten, so a static key means a permanently stale cache.
- Verify with logs and step timings, and keep main building so pull requests have a warm cache to restore.
Further reading: the official Next.js CI build caching guide and the actions/cache README.
Written by Admin
Published October 3, 2026 · Updated Oct 4, 2026