A working GitLab CI pipeline for Next.js needs four things in order: a pinned Node image so builds are reproducible, npm ci with the dependency cache keyed on the lockfile so installs aren’t reinstalling from scratch every run, separate lint, test and build stages so a broken build doesn’t hide behind a passing lint job, and a deploy stage that matches how the app is actually built, since static export only works if nothing in the app needs a server. This walks through a complete .gitlab-ci.yml for a Next.js project stage by stage, the caching that keeps builds fast, and what changes when the app uses output: standalone instead of static export.
Next.js on GitLab CI is well trodden ground, but most examples online stop at one working job rather than a full pipeline, and GitLab’s own official Next.js example only covers static export to GitLab Pages, which breaks the moment an app has an API route or server-rendered page. This puts together a pipeline that mirrors what a real Next.js project needs: caching that actually speeds up repeat runs instead of caching the wrong directory, lint and test jobs that run in parallel with the build, artifacts that hand the compiled output to a deploy stage instead of rebuilding it, and two deploy paths, one for a fully static site and one for an app that needs a live Node process behind it. The YAML is meant to be copied and trimmed, not read as theory.
Table of contents
- The base: image, stages, and variables
- Caching node_modules and the npm cache correctly
- Lint and test as parallel, fast-failing jobs
- The build stage and what to hand off as artifacts
- Deploy option one: static export to GitLab Pages
- Deploy option two: a real Node runtime for standalone output
- Environment variables and secrets across stages
- How this fits the rest of the stack
- FAQ
The base: image, stages, and variables
Start by pinning the Node image to the version the project actually targets, not node:latest, since a CI run that silently moves to a new major Node version on a schedule is a bad way to discover a breaking change. Declare the pipeline’s stages up front so job ordering is explicit rather than inferred.
image: node:20
stages:
- install
- lint
- test
- build
- deploy
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
NEXT_TELEMETRY_DISABLED: "1"
NEXT_TELEMETRY_DISABLED stops Next.js’s own telemetry ping from running in CI, which is a small but real speedup and avoids an outbound network call on every job. NPM_CONFIG_CACHE points npm’s cache at a path inside the project directory so GitLab’s cache mechanism, which only caches paths under the project’s working directory, can actually reach it; pointing it at npm’s default location outside the project silently produces an empty cache every run.
Five stages is more than a minimal pipeline needs, but keeping install separate from lint and test makes the job list easier to read in GitLab’s pipeline view, and it costs nothing extra since GitLab only runs a stage’s jobs once its dependencies are satisfied, not on a fixed schedule.
Caching node_modules and the npm cache correctly
GitLab CI cache is keyed and scoped per job unless explicitly shared, and the fastest, most reliable Next.js setup keys the cache on the lockfile so a cache hit means the exact same dependency tree, not an approximately similar one.
.npm_cache: &npm_cache
key:
files:
- package-lock.json
paths:
- .npm/
- node_modules/
policy: pull-push
Reference that anchor from each job that needs dependencies rather than repeating the block, and use npm ci instead of npm install in every job. npm ci requires a lockfile, always produces the exact versions in it, and removes node_modules before installing, which is what makes cached dependencies safe to trust across runs. A second cache, keyed separately, for .next/cache speeds up the build stage specifically: Next.js’s own build cache persists compiled output between builds and can cut build time meaningfully on a large app, at the cost of one more cache entry to manage.
cache:
key: next-build-cache
paths:
- .next/cache/
A cache miss isn’t a failure, it just means the job installs from scratch the way it would without any cache configured at all, so a new lockfile after a dependency bump costs one slow run rather than breaking the pipeline.
Lint and test as parallel, fast-failing jobs
Lint and test don’t depend on each other, so running them as separate jobs in the same stage, or adjacent stages, lets GitLab run them concurrently rather than one blocking the other. Both should fail the pipeline immediately rather than being treated as advisory. Running them in the same stage, rather than sequential stages, is what actually gets the concurrency: GitLab schedules every job in a stage to start as soon as its needs are met, so two independent jobs in one stage run on separate runners at the same time rather than waiting on each other.
lint:
stage: lint
<<: *npm_cache
script:
- npm ci
- npm run lint
test:
stage: test
<<: *npm_cache
script:
- npm ci
- npm test -- --ci
The —ci flag on the test script matters for test runners like Jest, which behave slightly differently in CI mode: no interactive watch prompts, and a non-zero exit code on any failure rather than a summary a script might otherwise swallow. Keeping lint and test as separate jobs, rather than combined into one script block, also means the pipeline’s job list shows exactly which one failed instead of a single opaque checks job.
The build stage and what to hand off as artifacts
The build job runs next build and needs to pass its output to the deploy stage without rebuilding, since rebuilding in the deploy job duplicates work and risks a different build if anything about the environment drifted between jobs.
build:
stage: build
<<: *npm_cache
script:
- npm ci
- npm run build
artifacts:
paths:
- .next/
- public/
expire_in: 1 hour
What belongs in artifacts.paths depends on the output mode. A static export (next export, or output: export in next.config.js) produces a plain out/ directory of HTML files that can go straight to any static host. A standard build produces .next/, which needs the Next.js server (next start) to run it, a Node process, not a folder of files. A build with output: standalone produces a .next/standalone directory containing a minimal server and only the dependencies actually used, small enough to copy into a lightweight deploy image without shipping the full node_modules tree. Picking the right one before writing the deploy stage avoids a pipeline that builds successfully and then has nothing usable to deploy.
Deploy option one: static export to GitLab Pages
If the app has no API routes and nothing that needs the Next.js server at request time, static export is the simplest deploy: GitLab Pages serves the out/ directory directly, for free, from GitLab’s own infrastructure, using a job named exactly pages.
pages:
stage: deploy
script:
- npx next build
- npx next export -o public
artifacts:
paths:
- public
only:
- main
This is also the limit of what GitLab’s own official Next.js Pages example covers, and it’s worth being precise about why: next export cannot include API routes, getServerSideProps, middleware, or any route that needs to run code on a request. If the app uses any of those, static export either fails the build or silently produces a broken page, and the pipeline needs the other deploy path instead.
The pages job name is not a stylistic choice; GitLab recognizes exactly that job name as the one that publishes to Pages, and a differently named job with identical content will build successfully but never actually publish anything, which is a confusing failure mode to debug the first time it happens.
Deploy option two: a real Node runtime for standalone output
An app using output: standalone, the common choice once server rendering, API routes or middleware are in play, needs somewhere to run node server.js, not a static file host. That’s a deploy target with a process manager, not a CDN, whether that’s a container registry push followed by a platform pulling the image, or a direct deploy from the repository.
deploy:
stage: deploy
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
only:
- main
Whether the image push triggers a redeploy automatically or needs a follow-up API call to the hosting platform depends entirely on what’s receiving it. On RunxBuild, connecting the repository once in the dashboard gets a Next.js service building straight from GitLab or GitHub, with its own build logs and a live route, which removes the Docker build-and-push step from the pipeline entirely for teams that would rather keep the platform doing the build. Either way, standalone output is the mode to reach for once the app needs anything Pages’ static export can’t run.
Environment variables and secrets across stages
Next.js needs build-time environment variables (anything prefixed NEXT_PUBLIC_ gets baked into the client bundle at build time, not read at runtime) and, for a standalone server, runtime variables too. GitLab CI/CD variables, set under Settings, then CI/CD, then Variables, cover both; masked and protected variants keep secrets out of job logs and off non-protected branches respectively.
The detail worth getting right: a NEXT_PUBLIC_ variable must be present at build time or it’s baked in as undefined for every visitor until the next rebuild, so the build job needs those variables available even if the deploy job is the one that ultimately runs the server. Our post on GitLab CI Environment Variables covers the masked, protected and file-type distinctions in more depth, GitLab Artifacts covers what else can move between jobs beyond the build output used here, and GitLab Pipeline Variables maps every variable type GitLab CI supports beyond what’s used in this pipeline.
How this fits the rest of the stack
The pipeline above is the shape almost every Next.js project on GitLab needs: cache the dependencies and the build cache separately, keep lint and test independent of the build, hand build output to deploy as an artifact instead of rebuilding it, and pick the deploy stage that matches the output mode rather than defaulting to static export because GitLab’s own example does.
Building the image and pushing it is the part that varies most between teams, and it’s usually the part worth automating away rather than maintaining by hand. The RunxBuild hosting calculator prices what a Next.js service costs to run continuously, next to the plan it would need, so the pipeline above can end in a docker push or skip that stage completely depending on which is less to maintain.
Useful related references:
- GitLab CI Environment Variables: The Masked, Protected, and File Types
- GitLab Artifacts: The Part of CI/CD That Actually Moves Files Between Jobs
- GitLab Pipeline Variables: A Practical Map of Every Kind
- Next.js services on RunxBuild
FAQ
How do I cache node_modules in GitLab CI for a Next.js project?
Key the cache on package-lock.json so a cache hit always matches the exact dependency tree, and cache both .npm/, npm’s own cache directory pointed inside the project via NPM_CONFIG_CACHE, and node_modules/. Use npm ci rather than npm install in every job; it requires the lockfile, installs the exact pinned versions, and removes node_modules first, which is what makes a cached copy safe to reuse across pipeline runs.
Can GitLab CI deploy a Next.js app to GitLab Pages for free?
Yes, but only for a fully static export, a job named pages that runs next export and publishes the out/ directory. It cannot serve API routes, getServerSideProps, or middleware, since Pages has no server process behind it. An app using any of those needs output: standalone and a real Node runtime instead, which GitLab Pages doesn’t provide.
What’s the difference between Next.js static export and standalone output in a GitLab pipeline?
Static export (next export) produces plain HTML files with no server, deployable anywhere that serves files, including GitLab Pages. Standalone output (output: standalone) produces a minimal Node server plus only the dependencies it actually uses, meant to run as a long-lived process. Static export works only for apps with no server-side rendering, API routes or middleware; standalone output is required for anything that does.
How do I pass environment variables to a Next.js build in GitLab CI?
Set them under Settings, CI/CD, Variables, using masked and protected flags to keep secrets out of job logs and off unprotected branches. Any variable prefixed NEXT_PUBLIC_ gets baked into the client bundle at build time, not read at runtime, so it must be available to the build job specifically; setting it only for the deploy job leaves it undefined in the compiled output.
Should the build and deploy stages run npm ci separately in GitLab CI?
It’s usually cheaper to build once and pass the output forward as an artifact than to reinstall dependencies and rebuild in the deploy stage. The build job runs npm ci and next build, then declares .next/, or out/, or .next/standalone depending on output mode, as an artifact; the deploy job consumes that artifact directly instead of repeating the install and build steps.