Published 25 October 2026. Tested on Ubuntu with Node.js 22, create-vite 9.3.1, React and Vitest. Source code: github.com/Deekshit7483/react-ci-demo · Live demo: deekshit7483.github.io/react-ci-demo
For my small web projects, "deploying" used to mean building on my laptop and uploading the files by hand, then hoping I hadn't forgotten to rebuild. So I set up GitHub Actions to do it properly: every push and pull request is linted, tested and built on GitHub's machines, and when code reaches main, the site is deployed to GitHub Pages automatically. If a check fails, nothing is deployed.
I built it from scratch on my Ubuntu laptop in one evening. The first complete run took 38 seconds, from push to live site. This post walks through exactly what I did, with screenshots from each step, including the two warnings I ran into along the way.
Contents
- What we're building
- Create the React app
- Make it a small real app
- Add a test with Vitest
- Set the base path for GitHub Pages
- Add the GitHub Actions workflow
- Push, turn on Pages and deploy
- Pull requests: check without deploying
- Problems and fixes
- What I learned
1. What we're building
| Event | What runs | Deploys? |
|---|---|---|
Push to main | Install → lint → test → build | Yes, if every step passes |
Pull request into main | Install → lint → test → build | No, it only shows pass/fail on the PR |
| "Run workflow" button | The same as a push | Yes |
The app is a simple React page that loads a list of users from a public test API with Axios. It's small on purpose; the point is the pipeline, and the same workflow works for any Vite project that builds to a dist/ folder.
You'll need: Node.js and npm (see Install Node.js and npm on Ubuntu), Git, VS Code and a GitHub account.
2. Create the React app
In a terminal, from the folder where you keep projects:
cd ~/Documents
npm create vite@latest react-ci-demo -- --template react
Two things happened here that are worth knowing about:
- An
EBADENGINEwarning. My laptop had Nodev22.9.0, and create-vite 9.3.1 asks for Node^20.19.0 || >=22.12.0. It's only a warning, but current Vite versions expect a recent Node, so the clean fix is to update Node to the current LTS. With nvm:nvm install --ltsthennvm alias default lts/*. - A new "Which linter to use?" question, offering Oxlint (the default) or ESLint. I chose ESLint, because it's what most React projects and tutorials use, and the template then adds a
lintscript that runseslint .. Oxlint is a newer, much faster linter and would also work; the workflow just runsnpm run linteither way.
Then install the dependencies and open the project in VS Code:
cd react-ci-demo
npm install
code .
3. Make it a small real app
In VS Code's terminal, add Axios (for the API call) and Vitest (for tests):
npm install axios
npm install -D vitest
Create a small helper in src/utils/formatName.js. It's the function we'll test:
export function formatName(user) {
if (!user || !user.name) return 'Unknown user'
return user.name.trim()
}
Then replace src/App.jsx with a component that fetches users and lists them:
import { useEffect, useState } from 'react'
import axios from 'axios'
import { formatName } from './utils/formatName'
import './App.css'
function App() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
axios
.get('https://jsonplaceholder.typicode.com/users')
.then((res) => setUsers(res.data))
.catch(() => setError('Could not load users.'))
.finally(() => setLoading(false))
}, [])
return (
<main style={{ maxWidth: 600, margin: '40px auto', fontFamily: 'system-ui' }}>
<h2>Hello, Deekshith H R!</h2>
<h1>User list</h1>
<p>Deployed automatically with GitHub Actions.</p>
{loading && <p>Loading…</p>}
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<ul>
{users.map((u) => (
<li key={u.id}>
<strong>{formatName(u)}</strong>: {u.email}
</li>
))}
</ul>
</main>
)
}
export default App
Run npm run dev and open http://localhost:5173:
The list comes from JSONPlaceholder, a free fake API for testing. For more on Axios (interceptors, cancelling requests, error handling), see my React + Vite + Axios tutorial.
4. Add a test with Vitest
Add a test script to package.json, next to the scripts Vite created:
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run"
}
Then create src/utils/formatName.test.js:
import { describe, it, expect } from 'vitest'
import { formatName } from './formatName'
describe('formatName', () => {
it('returns the trimmed name', () => {
expect(formatName({ name: ' Leanne Graham ' })).toBe('Leanne Graham')
})
it('handles a missing user', () => {
expect(formatName(null)).toBe('Unknown user')
})
})
Before pushing anything, run the same three commands GitHub will run. If they pass on your laptop, they'll almost always pass on GitHub:
npm test
npm run lint
npm run build
Use vitest run rather than plain vitest in the script: plain vitest starts watch mode and waits for changes, which would leave the GitHub job hanging until it times out.
5. Set the base path for GitHub Pages
A GitHub Pages project site lives in a sub-folder, https://your-username.github.io/your-repo/. Vite needs to know this, or the live page loads blank because it looks for its JavaScript files in the wrong place. Open vite.config.js and add base:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/react-ci-demo/', // your repository name, with both slashes
})
A side effect I noticed: after this change, npm run dev serves the app at http://localhost:5173/react-ci-demo/ instead of the root, matching the path it will have on GitHub Pages:
If you use a custom domain, or your repository is named your-username.github.io, use base: '/' instead.
6. Add the GitHub Actions workflow
In the project root (next to package.json), create the folders .github/workflows/ and a file named ci-deploy.yml:
name: CI and deploy
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Lint, test and build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out code
uses: actions/checkout@v7
- name: Set up Node.js (LTS) with npm cache
uses: actions/setup-node@v7
with:
node-version: lts/*
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Upload site for GitHub Pages
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v5
with:
path: dist
deploy:
name: Deploy to GitHub Pages
needs: check
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Configure Pages
uses: actions/configure-pages@v6
- name: Deploy
id: deployment
uses: actions/deploy-pages@v5
What the important parts do:
- Two jobs.
checkinstalls, lints, tests and builds.deployhasneeds: check, so it only starts if every check passed. A failing test can never reach the live site. if: github.event_name != 'pull_request'stops pull requests from uploading or deploying. They're checked, but the live site only changes when code lands onmain.npm ciinstalls exactly the versions inpackage-lock.json, so GitHub builds with the same packages as your laptop. Commit the lock file.cache: npmkeeps downloaded packages between runs.node-version: lts/*always uses the current Node.js LTS on GitHub, even though my laptop was on an older 22.x release.permissionsgives the workflow just enough access to publish to Pages and nothing more.
The action versions are the ones used in Vite's own GitHub Pages guide.
7. Push, turn on Pages and deploy
Create an empty public repository called react-ci-demo on GitHub (no README or .gitignore), then push the project. If you're new to this, my guide Push a Project to GitHub from the Ubuntu Terminal covers SSH keys and the first push in detail.
git init
git add .
git commit -m "add React app and GitHub Pages workflow"
git branch -M main
git remote add origin git@github.com:your-username/react-ci-demo.git
git push -u origin main
Vite's template already includes a .gitignore that excludes node_modules and dist, so only source files are pushed. GitHub builds dist itself.
Next, tell GitHub Pages to take the site from Actions. In the repository, open Settings → Pages and set Source to GitHub Actions:
My first push happened before I'd changed this setting, so I opened the run in the Actions tab and clicked Re-run all jobs. This time both jobs went green:
- Lint, test and build: 17 seconds.
- Deploy to GitHub Pages: 11 seconds.
- Total: 38 seconds, status Success, and the deploy job links straight to the live site.
A nice surprise: the job summary includes a Vitest Test Report (1 test file, 2 tests passed) without any extra setup. Vitest detects it's running inside GitHub Actions and writes the summary itself.
And the site was live at https://deekshit7483.github.io/react-ci-demo/:
8. Pull requests: check without deploying
To test the other half of the workflow, I changed the subtitle on a separate branch and opened a pull request:
git switch -c feature/new-heading
# edit the <p> text in src/App.jsx
git commit -am "Change heading"
git push -u origin feature/new-heading
On GitHub, I clicked Compare & pull request → Create pull request. The Files changed tab shows the one-line change:
The Actions tab shows all three runs side by side:
- #1, the first push to
main: 38 seconds, including the deploy. - #2, the pull request (branch
feature/new-heading): 21 seconds. Only lint, test and build ran; the deploy job was skipped, so the live site didn't change. - #3, the merge into
main, in progress. This one deploys.
Shortly after merging, the live site showed the new text, with no manual upload:
Optional: to stop anyone merging a pull request while its checks are red, go to Settings → Rules → Rulesets → New branch ruleset, target main, tick Require status checks to pass, add Lint, test and build, and set it to Active.
9. Problems and fixes
| Problem | Cause and fix |
|---|---|
npm warn EBADENGINE Unsupported engine when creating the project | Your Node is older than Vite wants (I had 22.9.0; it asked for 22.12+). Update to the current LTS with nvm install --lts. The workflow already uses the LTS on GitHub. |
| First workflow run fails at the deploy step | Pages wasn't set to GitHub Actions yet. Change it in Settings → Pages, then Re-run all jobs. |
| Live site is a blank white page | base in vite.config.js doesn't match the repository name. It must be '/react-ci-demo/' with both slashes. |
npm ci fails on GitHub | package-lock.json is missing or out of date. Run npm install locally and commit the lock file. |
| Test step never finishes | The script runs vitest (watch mode). Change it to vitest run. |
Refreshing a page like /about gives 404 | GitHub Pages doesn't know React Router routes. Use HashRouter, or copy dist/index.html to dist/404.html after the build. |
10. What I learned
- It's faster than doing it by hand. 38 seconds from
git pushto a live site, and I don't have to remember to build first. - Pull requests become safe to merge. The PR check took 21 seconds and never touched the live site; only the merge deployed.
- Read the warnings. The
EBADENGINEwarning was easy to ignore, but it's a reminder to keep Node on a supported LTS. Settingnode-version: lts/*in the workflow means GitHub's side is always up to date. - Next step: preview deployments for each pull request, and end-to-end tests with Playwright before deploying.
The complete project, including the workflow, is on GitHub: Deekshit7483/react-ci-demo.
Prefer static HTML without a build step? See Deploy an HTML Website on GitHub Pages. Want Firebase instead? See Host a Website with Firebase Hosting.











