This guide builds a GitLab CI/CD pipeline for a React app that runs tests, produces a production build, and deploys it automatically to GitLab Pages. You’ll solve the common “works on my machine” problem by making builds reproducible and deployments automatic on every push to main. Prerequisites: Node.js 20+, a GitLab account with a project you can push to, and basic familiarity with Git and React.
Setup: Create the React app and prepare the repo
We’ll create a React app with Vite because it’s fast and has a clean build output (a dist folder) that maps nicely to CI artifacts and GitLab Pages. We’ll also add a simple test setup so the pipeline can fail early when code breaks. The end result is a repo where local commands match what CI runs.
1) Create the project
Run the following commands to scaffold a React + TypeScript app, install dependencies, and verify it runs locally. This mirrors what CI will do later, so you want it clean and repeatable. If your local build passes, CI should pass too.
mkdir react-gitlab-cicd
cd react-gitlab-cicd
npm create vite@latest . -- --template react-ts
npm install
npm run dev -- --host 0.0.0.0 --port 5173
Expected result: your terminal prints a local URL like http://localhost:5173, and the default Vite React page loads in your browser.
⚠️ Note: If you’re on Node 18, Vite and tooling may still work, but your CI image should match local. This tutorial uses Node 20 to avoid “works locally, fails in CI” issues caused by version mismatches.
2) Add tests (Vitest + React Testing Library)
CI needs a fast, deterministic test command. Vitest integrates well with Vite, and React Testing Library gives you user-focused tests that catch regressions without being brittle. We’ll add a single test so you can see the pipeline fail if it breaks.
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @types/testing-library__jest-dom
Now create a Vitest config so tests run in a browser-like environment (jsdom). This is required for React components that touch the DOM.
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
},
});
Create the test setup file to enable helpful DOM matchers like toBeInTheDocument. This keeps your test code clean and readable.
import "@testing-library/jest-dom";
Update package.json to include a stable test command for CI. CI should call the same scripts developers run locally.
{
"name": "react-gitlab-cicd",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@types/testing-library__jest-dom": "^6.0.0",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^24.1.3",
"typescript": "^5.5.4",
"vite": "^5.4.2",
"vitest": "^2.0.5"
}
}
Create a tiny component and a test for it. This gives CI something real to validate and makes failures obvious.
import React from "react";
export function StatusBadge({ status }: { status: "ok" | "error" }) {
const label = status === "ok" ? "All systems go" : "Something failed";
const color = status === "ok" ? "#0ea5e9" : "#ef4444";
return (
<div
role="status"
aria-label="build-status"
style={{
display: "inline-block",
padding: "8px 12px",
borderRadius: 999,
background: color,
color: "white",
fontFamily: "system-ui, sans-serif",
}}
>
{label}
</div>
);
}
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { StatusBadge } from "./StatusBadge";
describe("StatusBadge", () => {
it("renders ok status text", () => {
render(<StatusBadge status="ok" />);
expect(screen.getByRole("status", { name: "build-status" })).toHaveTextContent(
"All systems go"
);
});
});
Wire the component into the app so you can see it locally. This also confirms the build output is what you expect to deploy.
import React from "react";
import ReactDOM from "react-dom/client";
import { StatusBadge } from "./StatusBadge";
import "./index.css";
function App() {
return (
<main style={{ padding: 24 }}>
<h2 style={{ fontFamily: "system-ui, sans-serif" }}>React + GitLab CI/CD</h2>
<p style={{ fontFamily: "system-ui, sans-serif" }}>
This badge is tested in CI and deployed via GitLab Pages.
</p>
<StatusBadge status="ok" />
</main>
);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Run tests locally to confirm everything is green before adding CI. CI should be boring: it runs the same commands and gets the same results.
npm test
npm run build
Expected result: tests pass, and vite build creates a dist/ folder.
⚠️ Note: Don’t commit dist/. GitLab Pages will serve build artifacts produced by CI, not files committed to the repo.
Implementation: Add GitLab CI jobs (test, build, deploy)
GitLab CI/CD is driven by a single file: .gitlab-ci.yml. We’ll create three stages: test (fast feedback), build (produces deployable assets), and deploy (publishes to GitLab Pages). Each stage will pass artifacts forward so you don’t rebuild unnecessarily.
1) Create the pipeline config
This config caches npm downloads for speed, runs tests on every push and merge request, builds only on main, and deploys to Pages from the build output. GitLab Pages expects a folder named public as the artifact for the pages job.
image: node:20-bullseye
stages:
- test
- build
- deploy
cache:
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull-push
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
NODE_ENV: "production"
test:
stage: test
script:
- npm ci
- npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
expire_in: 7 days
paths:
- dist/
rules:
- if: $CI_COMMIT_BRANCH == "main"
pages:
stage: deploy
script:
- rm -rf public
- mkdir -p public
- cp -r dist/* public/
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == "main"
Expected result: when you push, GitLab shows a pipeline with a test job, then build, then pages (on main). If tests fail, the pipeline stops before building or deploying.
⚠️ Note: If your project uses pnpm or yarn, don’t keep npm ci in CI. Mixing package managers is a common cause of lockfile and cache weirdness.
2) Push to GitLab
CI only runs once GitLab has the repo. Commit your changes, push to a GitLab project, and watch the pipeline run. This is also where you confirm your runner can install dependencies and execute tests.
git init
git add .
git commit -m "Add React app with tests and GitLab CI pipeline"
git branch -M main
git remote add origin https://gitlab.com/YOUR_NAMESPACE/YOUR_PROJECT.git
git push -u origin main
Expected result: in GitLab, go to CI/CD → Pipelines and see the pipeline succeed. The pages job should upload a public artifact.
Implementation: Configure GitLab Pages and fix base path issues
GitLab Pages serves your site under a URL that often includes your project name, like https://namespace.gitlab.io/project. Single-page apps and Vite builds need the correct base path so assets load from the right place. Without this, you’ll see a blank page or 404s for JS/CSS files.
1) Set the Vite base URL for GitLab Pages
Vite uses base to prefix asset URLs. For GitLab Pages, the safe default is to set base to the repository name when building in CI, but keep “/” locally. We’ll do this by reading an environment variable.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const repoBase = process.env.VITE_BASE_PATH ?? "/";
export default defineConfig({
plugins: [react()],
base: repoBase,
});
Now update the CI build job to set VITE_BASE_PATH automatically. GitLab provides CI_PROJECT_NAME, which matches the repo name used in the Pages URL.
image: node:20-bullseye
stages:
- test
- build
- deploy
cache:
key:
files:
- package-lock.json
paths:
- .npm/
policy: pull-push
variables:
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm"
NODE_ENV: "production"
test:
stage: test
script:
- npm ci
- npm test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
build:
stage: build
script:
- npm ci
- VITE_BASE_PATH="/$CI_PROJECT_NAME/" npm run build
artifacts:
expire_in: 7 days
paths:
- dist/
rules:
- if: $CI_COMMIT_BRANCH == "main"
pages:
stage: deploy
script:
- rm -rf public
- mkdir -p public
- cp -r dist/* public/
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == "main"
Expected result: after the next successful pipeline on main, your deployed site loads with correct CSS/JS, not a blank page.
⚠️ Note: If your GitLab Pages URL is at the root (user/organization Pages), the base path should be “/”. Project Pages usually need “/project-name/”. Check Deploy → Pages in GitLab for the exact URL.
2) Enable Pages in GitLab
GitLab Pages becomes available when the pages job uploads the public artifact. You then access the URL from the project settings. This is a deployment you can reproduce on every push to main.
- Go to your GitLab project → Deploy → Pages.
- Find the Pages URL GitLab provides.
- Open it and confirm the React app renders.
Expected result: you see the “React + GitLab CI/CD” heading and the status badge on the Pages URL.
Testing/Verification: Prove CI and deployment behave correctly
You want to verify three things: CI fails on broken code, CI produces a build artifact, and Pages updates after merges to main. The checks below are quick and catch the most common pipeline mistakes.
-
Verify tests gate the pipeline: edit the test to expect the wrong text, commit, and push to a branch or MR.
git checkout -b break-tests # edit src/StatusBadge.test.tsx to expect "Nope" git add src/StatusBadge.test.tsx git commit -m "Break test on purpose" git push -u origin break-testsExpected result: the test job fails and the pipeline stops before build/deploy.
-
Verify build artifacts exist: on a successful main pipeline, open the build job and confirm it uploads dist/ as an artifact.
Expected result: GitLab shows an artifact browser containing your built assets.
-
Verify Pages updates: merge a small UI change to main (like changing the heading), then wait for the pipeline to finish.
git checkout main git pull # make a small change in src/main.tsx git add src/main.tsx git commit -m "Change heading text" git pushExpected result: the Pages URL shows the updated heading after the pages job completes.
⚠️ Note: If Pages shows an old version, check your browser cache first, then confirm the latest pipeline ran on main. Also confirm the pages job artifact contains the new files (GitLab serves what’s in public).
Now you have a React app with a GitLab CI/CD pipeline that runs tests, builds on main, and deploys to GitLab Pages with the correct base path. Next steps: add ESLint as a separate CI job, publish preview deployments for merge requests (review apps), and add end-to-end tests (Playwright) as a nightly pipeline to catch routing and asset-loading issues.