A React app built with Vite (or the older Create React App) compiles to plain static files: an index.html, JavaScript bundles and assets. Nginx serves those faster than any Node server, using almost no resources. The only special requirement is client-side routing — handled with one line of config.

1. Build the app

On your machine or in CI:

npm ci
npm run build

Vite outputs to dist/; Create React App to build/. Environment variables are baked in at build time (VITE_* or REACT_APP_*), so set production values before building. Never put secrets in them — everything in the bundle is public.

2. Upload to the server

rsync -avz --delete dist/ [email protected]:/var/www/myapp/

--delete removes old files that are no longer part of the build. See rsync examples.

On the server, make sure Nginx can read the files:

sudo chown -R deploy:www-data /var/www/myapp
sudo find /var/www/myapp -type d -exec chmod 755 {} +
sudo find /var/www/myapp -type f -exec chmod 644 {} +

3. Nginx config

/etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name app.example.com;

    root /var/www/myapp;
    index index.html;

    # Hashed build assets: cache for a year
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # index.html must always be revalidated so users get new deploys
    location = /index.html {
        add_header Cache-Control "no-cache";
    }

    # SPA routing: unknown paths fall back to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}

For Create React App, hashed files live under /static/ instead of /assets/ — adjust the location.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Why try_files matters

With React Router, a URL like /dashboard/settings exists only in JavaScript. When a user refreshes that page or opens a shared link, the browser asks the server for /dashboard/settings — and without the fallback, Nginx returns 404. try_files $uri $uri/ /index.html serves the file if it exists, otherwise the app shell, and React Router renders the right view.

A side effect: truly missing pages return 200 with the app shell. Handle “not found” inside the app with a catch-all route.

4. Caching strategy

Build tools put a content hash in asset file names (index-3f9a2c1b.js). A new build produces new names, so those files can be cached “forever”. index.html keeps its name and points to the current hashed files, so it must not be cached long — hence no-cache (the browser revalidates each time, cheaply with ETags).

Get this wrong and users see a broken mix of old and new code after a deploy.

5. Compression

Enable gzip (and Brotli if available) for JS, CSS and JSON — see gzip and Brotli in Nginx. Vite plugins can also produce .gz/.br files at build time for gzip_static.

6. HTTPS

sudo certbot --nginx -d app.example.com

7. Proxy the API (avoid CORS)

If your backend runs on the same server, serve it under the same domain so the browser doesn’t need CORS:

location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

The React app calls /api/... with relative URLs. Place this block before the catch-all location /.

8. Automate deploys

A GitHub Actions job can build and rsync on every push to main. Upload to a new directory and switch a symlink so visitors never see a half-uploaded build:

RELEASE=/var/www/releases/$(date +%Y%m%d%H%M%S)
rsync -az dist/ deploy@server:$RELEASE/
ssh deploy@server "ln -sfn $RELEASE /var/www/myapp && ls -1dt /var/www/releases/* | tail -n +6 | xargs rm -rf"

The second command keeps the five most recent releases for quick rollback. See GitHub Actions deploy to a VPS.

Security headers

Static apps benefit from a few headers:

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;

A Content Security Policy is the strongest protection against XSS — see Content Security Policy.

Troubleshooting

  • 404 on refresh — missing try_files … /index.html.
  • Blank page, console shows 404 for JS files — the app was built for a sub-path; set Vite’s base (or CRA’s homepage) to match where it’s served.
  • Old version after deploy — index.html is cached; check its Cache-Control.
  • API calls fail with CORS errors — proxy the API under the same domain, or configure CORS on the backend.

The same setup works for Vue, Svelte, Angular and Astro static builds. Static sites need very little — even the smallest VPS plan serves them at high traffic.

  • React
  • Nginx
  • Deployment