Skip to content

SPA Deployment β€” Deploying Single-Page Applications to Production

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about SPA Deployment. We cover key concepts, practical examples, and best practices to help you master this topic.

SPA deployment covers hosting strategies, CDN configuration, build optimization, environment variable management, CI/CD pipelines, and deploying to Netlify, Vercel, AWS S3 with CloudFront, and custom servers.

What You'll Learn

By the end of this tutorial, you will understand how to deploy SPAs to production including build optimization for deployment, CDN configuration for global performance, environment management across staging and production, CI/CD pipeline setup, and deployment to multiple hosting platforms.

Why It Matters

A perfectly built SPA is useless if it is not deployed correctly. Misconfigured deployments cause blank pages on route refresh (404 errors for client-side routes), slow load times from improper caching, and security issues from exposed environment variables. Proper deployment is the final step that makes your app accessible to users.

Real-World Use

A React SPA deployed to AWS S3 with CloudFront initially served 404 errors on all routes except the home page. Users could not bookmark or refresh any page. After configuring error page redirects to index.html with a 200 status, all routes worked correctly. Page load time dropped from 4.2s to 1.1s after enabling CloudFront caching and Brotli compression.

SPA Deployment Architecture
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚                  SPA Production Architecture              β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚                                                          β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
    β”‚  β”‚ Developer │───>β”‚  CI/CD   │───>β”‚  Build Artifact  β”‚   β”‚
    β”‚  β”‚ (git push)β”‚    β”‚ Pipeline β”‚    β”‚  (dist folder)   β”‚   β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
    β”‚                                           β”‚              β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
    β”‚  β”‚              CDN / Static Hosting                  β”‚   β”‚
    β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚   β”‚
    β”‚  β”‚  β”‚ Netlifyβ”‚  β”‚ Vercel β”‚  β”‚ S3 +   β”‚  β”‚ Nginx  β”‚  β”‚   β”‚
    β”‚  β”‚  β”‚        β”‚  β”‚        β”‚  β”‚ Cloud  β”‚  β”‚ Server β”‚  β”‚   β”‚
    β”‚  β”‚  β”‚        β”‚  β”‚        β”‚  β”‚ Front  β”‚  β”‚        β”‚  β”‚   β”‚
    β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚   β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
    β”‚                                                          β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
    β”‚  β”‚              Critical Configurations              β”‚     β”‚
    β”‚  β”‚  β€’ Index document: index.html                     β”‚     β”‚
    β”‚  β”‚  β€’ Error document: index.html (for SPA routing)  β”‚     β”‚
    β”‚  β”‚  β€’ Cache headers: immutable for hashed assets    β”‚     β”‚
    β”‚  β”‚  β€’ Compression: Brotli / Gzip                    β”‚     β”‚
    β”‚  β”‚  β€’ HTTPS: Enforce TLS 1.3                        β”‚     β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Think of deploying an SPA like moving into a new house. The build process is packing your belongings (bundling files). The CDN is the moving truck that delivers everything. The configuration (error page redirects, cache headers) is like setting up the utilities β€” without them, the house looks nice but nothing works. The CI/CD pipeline is a moving crew that packs the same way every time.

SPA Route Handling Configuration

// Netlify configuration β€” _redirects file
// This ensures client-side routes work on refresh
/*
    /*    /index.html   200
    /api/*  https://api.example.com/:splat  200

    # Cache headers for static assets
    /static/*    Cache-Control: public, max-age=31536000, immutable
    /assets/*    Cache-Control: public, max-age=31536000, immutable
    /*.js        Cache-Control: public, max-age=31536000, immutable
    /*.css       Cache-Control: public, max-age=31536000, immutable
    /*.svg       Cache-Control: public, max-age=31536000, immutable
    /*.png       Cache-Control: public, max-age=31536000, immutable

    # No cache for index.html
    /index.html  Cache-Control: public, max-age=0, must-revalidate
*/

// Nginx configuration for SPA
/*
    server {
        listen 443 ssl;
        server_name example.com;

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

        # SPA route handling β€” serve index.html for all routes
        location / {
            try_files $uri $uri/ /index.html;
        }

        # Cache static assets forever (hashed filenames)
        location /static/ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # Proxy API requests
        location /api/ {
            proxy_pass https://api.example.com/;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        # Security headers
        add_header X-Frame-Options "DENY";
        add_header X-Content-Type-Options "nosniff";
        add_header Referrer-Policy "strict-origin-when-cross-origin";
    }
*/

CI/CD Pipeline Configuration

# GitHub Actions workflow for SPA deployment
name: Deploy SPA

on:
    push:
        branches: [main]
    pull_request:
        branches: [main]

jobs:
    build-and-deploy:
        runs-on: ubuntu-latest

        steps:
            - uses: actions/checkout@v4

            - name: Setup Node.js
              uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: 'npm'

            - name: Install dependencies
              run: npm ci

            - name: Lint and type check
              run: |
                  npm run lint
                  npm run typecheck

            - name: Run tests
              run: npm test -- --coverage

            - name: Build
              run: npm run build
              env:
                  VITE_API_URL: https://api.example.com
                  VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
                  VITE_GA_ID: ${{ secrets.GA_ID }}

            - name: Deploy to Netlify
              uses: nwtgck/actions-netlify@v3
              with:
                  publish-dir: ./dist
                  production-branch: main
                  deploy-message: "Deploy from GitHub Actions"
              env:
                  NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
                  NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

Environment Management

// Vite environment variables
// .env.production
VITE_API_URL=https://api.example.com
VITE_SENTRY_DSN=https://xxx@sentry.io/yyy
VITE_GA_ID=G-XXXXXXXXXX
VITE_ENVIRONMENT=production

// .env.staging
VITE_API_URL=https://staging-api.example.com
VITE_SENTRY_DSN=https://xxx@sentry.io/yyy
VITE_GA_ID=G-YYYYYYYYYY
VITE_ENVIRONMENT=staging

// Usage in code
const API_URL = import.meta.env.VITE_API_URL;
const isProduction = import.meta.env.VITE_ENVIRONMENT === 'production';

// Validate required env vars at build time
function validateEnv() {
    const required = ['VITE_API_URL'];
    const missing = required.filter(key => !import.meta.env[key]);

    if (missing.length > 0) {
        throw new Error(
            `Missing required environment variables: ${missing.join(', ')}`
        );
    }
}

validateEnv();

Build Optimization for Deployment

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
    plugins: [
        react(),
        visualizer({
            filename: 'dist/stats.html',
            open: false,
            gzipSize: true,
            brotliSize: true
        })
    ],

    build: {
        outDir: 'dist',
        sourcemap: false,
        minify: 'terser',
        terserOptions: {
            compress: {
                drop_console: true,
                drop_debugger: true
            }
        },
        rollupOptions: {
            output: {
                manualChunks: {
                    vendor: ['react', 'react-dom', 'react-router-dom'],
                    ui: ['@mui/material', '@emotion/react'],
                    charts: ['recharts'],
                    utils: ['date-fns', 'axios', 'zustand']
                }
            }
        },
        // Enable Brotli compression
        reportCompressedSize: true,
        // Chunk size warnings
        chunkSizeWarningLimit: 200
    }
});

Common Mistakes

  1. No SPA route fallback. Deploying an SPA without configuring the server to serve index.html for all routes results in 404 errors when users refresh or directly access any route other than the root.
  2. Caching index.html. If index.html is cached by the CDN with a long TTL, users never receive updated versions of the app. Always set Cache-Control: no-cache for index.html.
  3. Exposing secrets in client-side code. Environment variables prefixed with VITE_ are inlined into the bundle at build time and visible in the browser. Never include API keys for services that run server-side.
  4. Skipping the build step in CI. Building locally and committing the dist folder leads to inconsistent builds and bloated repositories. Always build in CI from a clean environment.
  5. No staging environment. Deploying directly to production without testing on a staging environment increases the risk of breaking changes reaching users. Always deploy to staging first.

Practice Questions

  1. Why does an SPA return a 404 error when the user refreshes a client-side route, and how do you fix it?
  2. What cache headers should you set for hashed static assets versus index.html?
  3. Why should environment variables be validated at build time?
  4. What is the purpose of a staging environment in the deployment pipeline?
  5. How do manual chunks improve caching of vendor libraries?

Challenge: Set up a complete deployment pipeline for an SPA: Vite configuration with manual chunks and minification, Netlify deployment with _redirects for SPA routing, GitHub Actions CI/CD workflow that runs lint, tests, and Type Checking before building, staging and production environments with different API URLs, and verify that the deployed app passes all routes with 200 status.

FAQ

Should I deploy SPAs to Vercel or Netlify?

Both are excellent. Vercel has better Next.js support. Netlify offers more generous free tier and simpler configuration. Choose based on your framework preference.

How do I handle API proxying in production?

Configure the reverse proxy (Nginx, CloudFront) to forward /api/* requests to your API server. On Netlify, use the _redirects or netlify.toml for proxy rules.

What is the best way to manage API URLs across environments?

Use environment variables (.env files) with different values per environment. Vite inlines VITE_ prefixed variables at build time. Do not hardcode URLs.

How do I deploy SPAs to AWS?

Build the SPA, upload the dist folder to S3, enable static website hosting, configure CloudFront for CDN, set error document to index.html, and add a CI/CD pipeline with GitHub Actions.

Should I use Docker for SPA deployment?

Docker is unnecessary for pure static SPAs since they are just HTML, CSS, and JS files. Use Docker only if you need a custom server (SSR, API proxy, authentication).

Mini Project

Deploy a complete SPA to production: set up a Vite-based React SPA with route-based Code Splitting, configure Netlify deployment with SPA route handling (_redirects), set up GitHub Actions CI/CD with lint, test, build, and deploy steps, configure staging and production environments, add cache headers for static assets, and verify the deployment with Lighthouse and curl tests.

What's Next

You understand SPA deployment. Now build a complete SPA mini project that combines everything you have learned.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro