DEV Community

Cover image for GitLab CI/CD for Next.js — Part 1: Validate Job Lint & Check Types
Kelvyn Thai
Kelvyn Thai

Posted on

GitLab CI/CD for Next.js — Part 1: Validate Job Lint & Check Types

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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+$/'
Enter fullscreen mode Exit fullscreen mode

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 develop branch
  • 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Breakdown

  • PNPM store caching We cache the PNPM store, which contains all downloaded dependencies. We do not cache node_modules because 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 extending node-job can 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)