Hi everyone 👋
I’m Kelvyn, a Frontend Engineer with 8 years of experience, mainly working with React and Next.js.
In this series, I’ll walk you through how to set up a comprehensive, production-grade GitLab CI/CD pipeline for a Next.js application, based on real-world experience running CI/CD at scale.
In Part 1, we’ll focus on building a fast and safe validation pipeline, including:
- Linting
- Type checking
- Environment-aware caching with PNPM
- Cost-efficient GitLab CI/CD execution
If you haven’t read it yet, you may want to start with:
👉 GitLab CI/CD for Next.js — Part 0: Project & Repository Setup
I. Setting up validation jobs (linting, type checking)
We’ll start by preparing the following files:
.gitlab.yml
Defines the CI pipeline and validation jobs at the project root.templates/node-job.yml
Prepares the Node.js Alpine image and sets up PNPM store caching.
References
.gitlab.yml
---
stages:
- validate
include:
- local: "templates/node-job.yml"
validate:
stage: validate
extends: .node-job # inherit from node-job template
needs: [] # trigger this job immediately
script:
- pnpm install --frozen-lockfile --ignore-scripts --prefer-offline
- pnpm lint
- pnpm check-types
At this point, we have a basic validation pipeline that ensures code quality before moving forward.
templates/node-job.yml
References
---
.node-job:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22.20.0-alpine3.22
variables:
PNPM_STORE_KEY: $CI_PROJECT_DIR/.pnpm-store
before_script:
- corepack enable && corepack prepare pnpm@10 --activate
- pnpm --version
- pnpm config set store-dir $PNPM_STORE_KEY
- export PNPM_HOME="$CI_PROJECT_DIR/.pnpm"
- export PATH="$PNPM_HOME:$PATH"
Great! Our validation job is now set up successfully.
Let’s commit the code and run the first pipeline.
- The pipeline finishes in 36 seconds, with dependency installation taking roughly ~8.1 seconds 👍
Next, we’ll improve this further by introducing GitLab CI/CD caching together with environment-based variables to avoid cache conflicts or accidental mutations between development, staging, and production.
In real projects, CI/CD cost and execution time matter.
Running validation on every commit quickly becomes noisy, slow, and expensive.
NEW: Create templates/rules.yml
Reference
---
.rules_dev:
rules:
- if: $CI_COMMIT_REF_SLUG == "develop"
.rules_staging:
rules:
- if: $CI_COMMIT_TAG =~ /^staging-\d+\.\d+\.\d+$/
.rules_prod:
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
.rules_protected:
rules:
- if: '$CI_COMMIT_REF_SLUG == "develop"'
- if: '$CI_COMMIT_TAG =~ /^staging-\d+\.\d+\.\d+$/'
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
We define a few rules to control when CI/CD jobs should run.
The validation jobs will only be triggered when:
- Code is merged into the
developbranch - A staging tag is created (e.g.
staging-1.0.0) - A production tag is created (e.g.
v1.0.0)
In all other cases, the pipeline won’t run — helping us save CI minutes and reduce unnecessary cost.
Update: .gitlab.yml
---
include:
- local: "templates/node-job.yml"
- local: "templates/rules.yml" # include rules template
# Convert validate job into a reusable template
.validate_job:
stage: validate
# old config...
cache:
policy: pull-push # allow validate jobs to revalidate cache when needed
# Validate Dev
validate_dev:
extends:
- .validate_job
- .rules_dev
variables:
ENV: "dev"
# Validate Staging
validate_staging:
extends:
- .validate_job
- .rules_staging
variables:
ENV: "staging"
# Validate Prod
validate_prod:
extends:
- .validate_job
- .rules_prod
variables:
ENV: "prod"
We now define separate validation jobs for each environment:
Each job:
- Runs only when the correct branch or tag is used
- Uses environment-specific variables for isolation
- Prevents PNPM cache pollution across environments
Update: templates/node-job.yml (cache configuration)
---
.node-job:
image: ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:22.20.0-alpine3.22
cache:
key:
files:
- pnpm-lock.yaml
prefix: $ENV
paths:
- $PNPM_STORE_KEY
policy: pull
Breakdown
-
PNPM store caching
We cache the PNPM store, which contains all downloaded dependencies.
We do not cache
node_modulesbecause compressing and transferring it is expensive and often slower.
If you want to understand why PNPM works so well, check out:
👉 PNPM Content-Addressable Store Looks Broken on macOS
Default cache policy:
pull
Jobs extendingnode-jobcan read from the cache only, preventing accidental cache mutation.Cache prefix:
$ENV
Scopes caches by environment (dev / staging / prod) to avoid conflicts and concurrency issues.Cache files:
pnpm-lock.yaml
Cache is revalidated only when dependencies change, keeping it stable and predictable.CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX
Allows GitLab to pull images and cached layers directly from the GitLab registry, instead of hitting Docker Hub or AWS ECR.
Let’s commit the code and review the cache behavior.
The pipeline now finishes slightly slower on the first run (~41s) because the cache needs to be downloaded, extracted, and pushed.
However, pay attention to the dependency installation time: ~2.1 seconds 🚀
That’s more than 4× faster compared to no caching.
As the project grows and node_modules becomes larger, this approach can save significant CI time and cost, especially when reused across unit tests, E2E tests, and future pipeline stages.
Summary
In Part 1, we focused on building a fast, safe, and cost-efficient validation pipeline for a Next.js application using GitLab CI/CD.
Here’s what we achieved:
- ✅ Set up linting and type checking as early validation gates
- ✅ Introduced reusable CI templates using
extends - ✅ Applied rule-based execution to run pipelines only on meaningful events (develop, staging, production)
- ✅ Implemented environment-aware PNPM store caching to avoid cache pollution
- ✅ Leveraged GitLab Dependency Proxy to reduce external registry traffic
- ✅ Reduced dependency installation time from ~8s → ~2s, saving CI time and cost
This foundation ensures that only high-quality, type-safe code moves forward — without wasting CI minutes on every commit.
What’s Next — Part 2: Unit Testing at Scale 🚀
In Part 2, we’ll level up the pipeline by introducing fast and scalable unit testing using Vitest.
We’ll cover:
- Setting up Vitest in GitLab CI/CD
- Running unit tests in parallel
- Test sharding to split large test suites across multiple jobs
- Reusing the PNPM store cache to keep test jobs blazing fast
- Reducing total pipeline time as the codebase grows
By the end of Part 2, you’ll have a production-ready testing pipeline that scales smoothly with your application — without sacrificing speed or cost.
👉 Stay tuned for GitLab CI/CD for Next.js — Part 2: Unit Testing with Vitest
*Full source code: *
https://gitlab.com/kelvyn-labs/nextjs-cicd-template


Top comments (0)