Setting Up a CI/CD Pipeline for a React App with GitLab CI/CD
Shipping React apps manually — running tests, building the bundle, deploying to a server — is error-prone and slow.
This guide walks you through building a full CI/CD pipeline with GitLab CI/CD that automatically tests, builds, and deploys your React app on every push.
By the end, you'll have a working .gitlab-ci.yml that runs lint checks, unit tests, creates a production build, and deploys to GitLab Pages.
Prerequisites: A GitLab account, Node.js 20+ installed locally, and basic familiarity with React and the command line.
1. Project Setup
Start with a fresh React app using Vite, which produces faster builds than Create React App and plays well with CI environments. Run these commands in your terminal:
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install
npm install --save-dev eslint eslint-plugin-react vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom jsdom
Open vite.config.js and add the Vitest configuration block so the test runner knows to use a browser-like environment:
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/my-react-app/', // Must match your GitLab repo name for Pages
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
})
Create the test setup file that loads the custom matchers from Testing Library:
// src/setupTests.js
import '@testing-library/jest-dom'
Now update package.json to add scripts the pipeline will call:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .js,.jsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
⚠️ Note: The
baseoption invite.config.jsmust match your GitLab repository name exactly (case-sensitive). If it's wrong, your deployed app will load a blank page because asset paths won't resolve correctly.
2. Writing a Test Before Wiring Up the Pipeline
The pipeline is only useful if there's something to test. Add a simple component and a test for it now so the CI has real work to do.
// src/components/Greeting.jsx
export function Greeting({ name }) {
return (
<div className="greeting">
<h1>Hello, {name}!</h1>
<p>Your pipeline is running.</p>
</div>
)
}
// src/components/Greeting.test.jsx
import { render, screen } from '@testing-library/react'
import { Greeting } from './Greeting'
describe('Greeting', () => {
it('renders the name prop', () => {
render(<Greeting name="GitLab" />)
expect(screen.getByRole('heading')).toHaveTextContent('Hello, GitLab!')
})
it('shows the pipeline message', () => {
render(<Greeting name="Dev" />)
expect(screen.getByText('Your pipeline is running.')).toBeInTheDocument()
})
})
Run the tests locally to confirm everything passes before touching the pipeline config:
npm test
Expected output:
✓ src/components/Greeting.test.jsx (2)
✓ renders the name prop
✓ shows the pipeline message
Test Files 1 passed (1)
Tests 2 passed (2)
3. Writing the GitLab CI/CD Pipeline
Create a .gitlab-ci.yml file at the root of your project. GitLab automatically detects this file and runs the pipeline on every push.
The structure below defines three sequential stages: validate, build, and deploy.
# .gitlab-ci.yml
image: node:20-alpine
stages:
- validate
- build
- deploy
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
# ── Validate Stage ──────────────────────────────────────────────────
lint:
stage: validate
script:
- npm ci --prefer-offline
- npm run lint
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
test:
stage: validate
script:
- npm ci --prefer-offline
- npm run test:coverage
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
artifacts:
when: always
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
paths:
- coverage/
expire_in: 7 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── Build Stage ─────────────────────────────────────────────────────
build:
stage: build
script:
- npm ci --prefer-offline
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ── Deploy Stage ────────────────────────────────────────────────────
pages:
stage: deploy
dependencies:
- build
script:
- cp -r dist public
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
environment:
name: production
url: https://$CI_PROJECT_NAMESPACE.gitlab.io/$CI_PROJECT_NAME
A few decisions worth explaining here:
npm ciinstead ofnpm install:npm ciinstalls exactly what's inpackage-lock.jsonand fails if there are discrepancies, making builds reproducible.- Cache keyed on
package-lock.json: The cache invalidates automatically when dependencies change, saving minutes per run when nothing has changed. - Artifacts on the test job with
when: always: Coverage reports upload even when tests fail, so you can see which lines broke coverage. dependencies: [build]on the pages job: This tells GitLab to download only thedist/artifact from the build job, not artifacts from every previous job.
⚠️ Note: The
pagesjob name is not arbitrary — GitLab Pages requires the job to be named exactlypagesand the artifact path to be exactlypublic/. Renaming either one will cause the deployment to silently do nothing.
4. Enabling GitLab Pages and Pushing
Push the project to a new GitLab repository. In your GitLab project, navigate to Settings → Pages and confirm GitLab Pages is enabled (it is by default on GitLab.com).
git init
git remote add origin [email protected]:your-username/my-react-app.git
git add .
git commit -m "feat: add react app with gitlab ci/cd pipeline"
git push -u origin main
After pushing, open your GitLab project and click Build → Pipelines. You'll see the pipeline trigger immediately. Each stage runs sequentially — if lint or tests fail, the build and deploy stages are skipped automatically.
A successful pipeline shows green checkmarks on all three stages. The Pages deploy URL appears under Deploy → Pages in the sidebar, usually formatted as https://your-username.gitlab.io/my-react-app/.
⚠️ Note: GitLab Pages can take up to 30 minutes to propagate after the first successful deploy. Subsequent deploys are much faster, typically under 2 minutes.
5. Verifying the Pipeline Works
Make a change that intentionally breaks a test to confirm the pipeline catches failures correctly:
git checkout -b test/verify-pipeline-catches-failures
// src/components/Greeting.jsx — intentionally broken
export function Greeting({ name }) {
return (
<div className="greeting">
<h1>Goodbye, {name}!</h1>
<p>Your pipeline is running.</p>
</div>
)
}
git add .
git commit -m "test: break greeting to verify pipeline failure"
git push origin test/verify-pipeline-catches-failures
Open a merge request targeting main. The validate stage will fail on the test job with a clear error showing which assertion failed.
Because the pipeline fails at the validate stage, the build and deploy jobs never run — your production deployment on main stays untouched.
Revert the change and push again to see the pipeline go green and the MR become mergeable:
git revert HEAD --no-edit
git push origin test/verify-pipeline-catches-failures
You now have a complete CI/CD pipeline that lints, tests with coverage reporting, builds, and deploys a React app to GitLab Pages on every push to main.
From here you can extend the pipeline with additional stages: add a security stage running npm audit, introduce environment-specific deployments using GitLab environments and protected branches, or replace GitLab Pages with a deploy job that pushes to AWS S3 or a container registry using docker build and GitLab's built-in container registry.
The pipeline structure you have now scales to all of those cases without major rework.