Next.js Builds on GitHub Actions

Restore .next/cache beside the package manager store, key it on the lockfile plus a source hash, and size warp- runners from 4 to 16 vCPU for next build.

Last verified:

To make next build fast on GitHub Actions, restore .next/cache and the package manager store at the start of the job, and put the build on a runner with enough cores to keep the compiler and the prerender workers busy. On WarpBuild runners both halves are one-line changes: WarpBuilds/cache is a drop-in replacement for actions/cache@v4, and runs-on picks a warp- label from 2 to 32 vCPU.

This page covers the workflow configuration for the two caches a Next.js build depends on, the cache key design that survives dependency churn, the sizing call between 4, 8, and 16 vCPU runners, the three bottlenecks that dominate Next.js pipelines, and a worked cost model for a build-and-preview pipeline against GitHub-hosted runner list prices.

Overview

Every GitHub Actions job starts on a fresh virtual machine, so .next/cache is empty unless a step puts it there. An empty .next/cache means next build recompiles every module in the app, even when the pull request touched one route file.

Three things live under .next/cache and all three matter. The compiler cache holds the transformed output and module graph metadata from the previous build, which is what lets an incremental build skip modules whose inputs did not move. The lint result cache sits there when ESLint runs as part of the build. Optimized image variants land in .next/cache/images when the server renders them.

A Next.js build has three phases with different resource shapes, and confusing them is how teams buy the wrong runner.

Compile transforms and bundles every module that the routes reach. This phase parallelizes across cores, and it is the phase the restored .next/cache shortens.

Type check runs TypeScript over the whole program in one single threaded pass. Cores do nothing for it. Heap size does.

Prerender renders every static route and every statically generated dynamic route. Next.js runs these in a pool of worker processes whose size follows the machine CPU count, and you can pin it with experimental.cpus. Each worker executes the route's own data fetching, so this phase is bounded by cores when rendering is CPU heavy and by network latency when routes fan out to an upstream API.

Next.js builds belong on the Linux runners, both because the sizes run up to 32 vCPU and because WarpBuild Cache is enabled by default on Linux runners and is not supported on Windows runners.

One boundary before the configuration. This page covers a single Next.js application with one lockfile and one build command. If the app is one package inside a task graph, output caching across packages is a different problem, covered on the Turborepo on GitHub Actions page.

Configuration

The workflow below installs with pnpm, restores .next/cache, and builds on an 8 vCPU WarpBuild runner. The package manager store is handled by WarpBuilds/setup-node, a drop-in replacement for actions/setup-node that routes dependency caching through WarpBuild Cache.

name: nextjs-build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Restore Next.js build cache
        uses: WarpBuilds/cache@v1
        with:
          path: .next/cache
          key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('app/**', 'src/**', 'next.config.*') }}
          restore-keys: |
            next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            next-${{ runner.os }}-

      - run: pnpm exec next build

The key has two hashed segments and the restore ladder has two rungs, and that shape is what keeps the build cache useful through dependency churn.

The exact key hits only when the lockfile and the source tree are both byte identical to a previous run, which happens on a rerun or a docs-only change. The first rung drops the source hash and keeps the lockfile hash, so an ordinary code change restores the compiler cache from the last build on the same dependency set and recompiles only what moved. The second rung drops both, so the run after a dependency bump still starts from a warm compiler cache instead of a cold one. Without that second rung, every automated dependency update pull request pays a full recompile, which is exactly the run where the cache is worth the most.

Three behaviors from the caching documentation shape how this performs in practice.

The cache is scoped to key, version, and branch. A cache saved on a feature branch is separate from the one on main, so seed the cache by running the build on main and let branch builds reach it through restore-keys.

The version is a hash over the compression tool and the list of cached paths. Two consequences follow: a cache saved on a macOS runner cannot restore on a Linux runner, and editing the path list produces a new version that ignores every existing entry. Change the paths deliberately and expect one cold build after you do.

Entries expire after 7 days of last use. An active repository stays warm on its own, and a branch nobody has touched for a week stops costing storage without any cleanup step.

Cache usage is metered on hosted runners at $0.20 per GB-month of storage and $0.0001 per write or restore operation, and it is included at no charge on BYOC runners. A Next.js repository holding a 2.5GB combined store and build cache with 4,000 operations a month adds about $0.90 to the bill.

The second piece of configuration is splitting type checking out of the build. Set typescript.ignoreBuildErrors and eslint.ignoreDuringBuilds in next.config, then run both checks as their own job on a smaller runner in parallel with the build:

  typecheck:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec tsc --noEmit
      - run: pnpm exec eslint .

The pull request still fails when types break, because the job still fails. What changes is that a single threaded tsc pass no longer sits inside the wall time of a build you are paying 8 cores for.

Sizing

WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core. Three sizes cover almost every Next.js application:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-4x416GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664GB150GB SSD$0.032

Two inputs decide the size: how many routes the build prerenders, and whether type checking runs inside next build.

warp-ubuntu-latest-x64-4x fits an app under roughly 100 routes with type checking split into its own job. The compile phase saturates 4 cores on an app this size, the prerender pool has few enough routes that more workers finish sooner than the pool can start them, and 16GB carries the Node heap through the module graph.

warp-ubuntu-latest-x64-8x is the default when either input grows. An app prerendering a few hundred routes keeps 8 prerender workers busy, and an app that still runs type checking inside the build needs the memory headroom more than the cores, because the type graph of a large program is what pushes the process past the default heap. If next build is dying with an out-of-memory error on 4x, move to 8x for the 32GB before reaching for NODE_OPTIONS=--max-old-space-size on a machine that cannot back the number you write.

warp-ubuntu-latest-x64-16x earns its rate above roughly 1,000 prerendered routes, or when the app runs output: 'export' and every route is rendered at build time. Watch for the ceiling: if the prerender phase is waiting on an upstream API rather than on CPU, 16 workers make 16 concurrent callers of that API and the wall time stops improving. Check the utilization before buying the cores.

A calibration step worth one run: build once on 8x and watch per-step utilization. If only the compile and prerender phases hold all 8 cores, the pipeline above is already shaped correctly, with the 4x job carrying the checks.

Worked cost model

GitHub publishes per-minute list prices for its hosted runners on the GitHub Actions minute multipliers reference, and the runner shapes on the GitHub-hosted runners reference. Checked on 2026-08-13, the Linux larger runners are $0.012 per minute for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU.

Runner labelvCPUWarpBuild rateGitHub larger runner rateLower by
warp-ubuntu-latest-x64-4x4$0.008/min$0.012/min33 percent
warp-ubuntu-latest-x64-8x8$0.016/min$0.022/min27 percent
warp-ubuntu-latest-x64-16x16$0.032/min$0.042/min24 percent

Take the pipeline on this page at a stated volume: 1,000 build-and-preview runs a month, where each run is a 6 minute build job on 8 vCPU and a 3 minute preview deploy job on 4 vCPU. That is 6,000 minutes at the 8 vCPU rate and 3,000 minutes at the 4 vCPU rate.

Line itemRateMonthly minutesMonthly cost
Build job, GitHub-hosted 8 vCPU$0.022/min6,000$132.00
Preview job, GitHub-hosted 4 vCPU$0.012/min3,000$36.00
GitHub-hosted total9,000$168.00
Build job, warp-ubuntu-latest-x64-8x$0.016/min6,000$96.00
Preview job, warp-ubuntu-latest-x64-4x$0.008/min3,000$24.00
Cache storage and operations (2.5GB, 4,000 ops)see aboven/a$0.90
WarpBuild total9,000$120.90

The difference on this pipeline is $47.10 a month, or 28 percent below the GitHub-hosted list price for the same two shapes. Stated per size: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list price checked on 2026-08-13.

Full rates for every size and platform are on the pricing page. If the preview environments need to sit inside your own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS.

Bottlenecks

Three bottlenecks account for most slow Next.js pipelines on GitHub Actions.

A cold .next/cache forcing a full recompile. Without a restored cache, every build transforms every module the routes reach, and the wall time tracks the size of the app rather than the size of the change. This is the single largest lever on a Next.js build and the reason the restore step comes before next build in the workflow above. Verify it from the build log: a warm build reports far fewer compiled modules than a cold one, and the compile phase finishes in a fraction of the cold time. If the cache restores but the build still recompiles everything, the usual cause is a key whose source hash pattern covers generated files that change on every run, which turns every key into a fresh one.

Type checking serialized into the build. next build type checks the program by default, in one single threaded pass, and that pass sits inside the build's wall time. Adding cores does nothing to it, so an app that spends four minutes compiling and three minutes type checking gets far less from a 16x runner than the utilization graph suggests. Move it to the parallel job shown in the configuration section. The check still gates the merge, and the build job stops paying for it.

Image work at build time. Statically imported images with placeholder="blur" make the build generate a blur placeholder for each one through sharp, and that work repeats on every cold build. Optimized variants written by the image optimizer land in .next/cache/images, which the cache step above already carries, so a warm build skips both. When the app has hundreds of static image imports, this phase becomes visible in the build log and the fix is the same restored cache rather than a larger runner. Apps that build with output: 'export' pay the most here, because every route and every image variant is produced at build time instead of on demand.

When a build is slow and the phase responsible is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound compile from a prerender phase waiting on an upstream API. For interactive work, the Action Debugger pauses the workflow and opens an SSH session on the runner, so you can inspect the restored .next/cache directory on the machine itself. Workflow-level tactics beyond the build itself are collected in the guide on speeding up GitHub Actions.

Proof

Every cost number on this page is list-price arithmetic against GitHub's published rates, with the source linked and the date stated, so you can re-run it yourself.

Public repositories running warp- labels are citable evidence, and reading a runs-on line takes a few seconds. The Trigger.dev TypeScript monorepo runs its end-to-end matrix on warp-ubuntu-latest-x64-4x and warp-windows-latest-x64-8x, which you can read in triggerdotdev/trigger.dev's e2e.yml (checked on 2026-08-13).

The full runner matrix, including the Linux ARM64 sizes that run the same builds at lower per-minute rates, is documented under cloud runners. If the same repository also installs dependencies for other jobs, the store configuration is covered on the Node.js on GitHub Actions and pnpm on GitHub Actions pages.

FAQ

Should I cache .next/cache or node_modules?

Cache .next/cache and the package manager store. node_modules is rebuilt by the install command and is welded to the Node version that produced it, while .next/cache holds the compiler output that decides whether next build recompiles one route or the whole app.

What should the .next/cache key hash?

The lockfile plus a hash of the source directories, with a restore-keys ladder underneath. The exact key gives a byte-identical cache when nothing changed, and the lockfile-only rung keeps a source edit from starting the build cold.

What runner size does next build need?

warp-ubuntu-latest-x64-4x for apps under about 100 routes with type checking split into its own job, 8x when type checking runs inside next build or the app prerenders a few hundred routes, and 16x above roughly 1,000 routes.

What changes when a Next.js workflow moves to WarpBuild?

The runs-on label on each job, and actions/cache becomes WarpBuilds/cache.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.