SPA React/Vue/Angular #

Serving Single Page Applications (SPAs) like React, Vue, Svelte, and Angular is one of the most common tasks run by modern web servers. Unlike traditional website applications that trigger full server-side page loads for every clicked menu link, SPAs move all routing logic to the client side (client-side routing). In the user’s browser, libraries like React Router or Vue Router handle navigation instantly by manipulating the browser URL history using the HTML5 History API without contacting your web server. This behavior creates a special technical challenge on the edge web server side: if a user does a page refresh or directly accesses a deep link route URL (like /dashboard/users/settings), the web server gets confused looking for the physical /dashboard/users/settings file on disk and returns a 404 Not Found error. Caddy solves this problem elegantly and efficiently using the try_files directive. We’ll discuss client-side routing concepts, practice optimal production configurations for compression and aggressive cache management, integrate with backend APIs on one domain, compose multi-stage Dockerfiles, handle dynamic environment variables, and configure automatic preview deployment subdomains for CI/CD integration.

Concept: Client-Side Routing vs Server-Side Routing #

To understand why special configuration is needed for SPAs, let’s compare the request journey in both routing architectures:

1. Server-Side Routing Scenario (Traditional) #

The browser requests the /about page. The server detects the /about.html folder or file, renders it, then returns it as an HTTP response. Every time the user changes pages, the browser clears the screen and triggers a full reload of all CSS/JS assets.

2. Client-Side Routing Scenario (SPA) #

  • First Visit: The browser downloads the main index.html file along with the CSS and JavaScript bundles (e.g., main.a1b2c3d4.js).
  • Page Interaction: The user clicks the “Profile” button. JavaScript cancels the browser’s default request, updates the URL in the address bar to /profile, then instantly renders the Profile component using local RAM data. No page reload happens.
  • The Refresh Problem: The user refreshes the browser page (F5/refresh) while on the /profile page. The browser immediately sends an HTTP GET /profile request to Caddy.
  • Without Special Configuration: Caddy searches your web folder, finds no file named /profile or /profile.html, then returns the 404 Not Found status.
  • With the try_files Solution: Caddy doesn’t find the /profile file, then intelligently returns the contents of the main /index.html file with HTTP 200 status. The SPA JavaScript loads, detects the active URL in the address bar is /profile, then renders the profile page automatically.

Basic SPA Configuration #

In Caddy, solving this client-side routing issue is very easy. You only need one try_files directive line in your Caddyfile:

# Basic configuration for all SPA frameworks (React, Vue, Svelte, Angular)
app.example.com {
    # Set the build folder of our compiled SPA
    root * /var/www/myapp/dist
    
    # Fallback rules:
    # 1. Try to find a physical file matching the URL ({path}).
    # 2. If none exists, route the request to the main index.html.
    try_files {path} /index.html
    
    # Enable the static file server
    file_server
}

With just the four-line configuration above, all deep links in your React/Vue application work perfectly when accessed directly or refreshed by users.


Complete Configuration with Cache Optimization #

JavaScript and CSS bundles from modern application compilations (Vite, Webpack) are usually large. To give the best performance scores on Google Lighthouse tests and speed up page load times, you must enable high-level data compression and compose proper browser caching policies.

By default, modern build tools insert a unique hash value in the produced file names (e.g., index-a1b2c3d4.js and style-5e6f7g8h.css). If your application code changes, the file name hashes change too when rebuilt. This lets you apply aggressive long-term caching on client browsers without fearing users getting old versions. However, the main index.html entry file and Service Worker files are strictly forbidden to be cached so browsers always detect the latest application updates.

Here’s the optimal SPA production configuration in the Caddyfile:

# SPA production configuration with Lighthouse performance optimization
app.example.com {
    root * /var/www/myapp/dist
    
    # Enable Zstd and Gzip compression for JS/CSS bundles (reducing size by up to 80%)
    encode zstd gzip
    
    # Insert standard security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
    
    # 1. Forever-Aggressive Caching (Hashed Assets):
    # All hashed JS, CSS, font, and image files are cached for 1 year.
    # The 'immutable' keyword tells browsers to never re-validate these files to the server.
    @hashed_assets path_regexp \.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|svg|ico)$
    header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
    
    # 2. index.html Caching Prohibition:
    # The index.html file must always fetch its latest version from the server
    # so browsers can detect new hashed JS file names at deploy time.
    @html path *.html /
    header @html Cache-Control "no-cache, no-store, must-revalidate"
    header @html Pragma "no-cache"
    header @html Expires "0"
    
    # 3. Service Worker Caching Prohibition:
    # Incorrectly indexing Service Workers can cause user browsers
    # to be stuck on the old application version forever.
    @sw path /service-worker.js /sw.js
    header @sw Cache-Control "no-cache, no-store, must-revalidate"
    
    # 4. Apply the SPA fallback routing
    try_files {path} /index.html
    
    # Serve static files
    file_server
}

SPA + Backend API on the Same Domain #

In some deployment cases, you want to serve your SPA frontend application and your backend API application under the same domain name (e.g., accessing the web at example.com and sending AJAX requests to example.com/api/*).

This approach has big advantages because it eliminates the need for complicated CORS configuration in the backend, and avoids third-party cookie blocking problems.

You use handle blocks to separate those route segments modularly so API requests aren’t affected by the SPA try_files fallback rules:

# Single domain configuration: SPA Frontend & API Backend
example.com {
    root * /var/www/myapp/dist
    encode zstd gzip
    
    # 1. API Routes: Forward directly to the backend server (Node.js/Python)
    # Use handle so this route is isolated from the static file_server process
    handle /api/* {
        reverse_proxy localhost:8080 {
            header_up X-Real-IP {remote_host}
            header_up X-Forwarded-Proto {scheme}
        }
    }
    
    # 2. WebSocket routes for real-time data
    handle /ws/* {
        reverse_proxy localhost:8080
    }
    
    # 3. Default Routes: Serve the SPA frontend application
    handle {
        # Fallback deep routes to index.html
        try_files {path} /index.html
        file_server
    }
}

SPA Docker Containerization with Caddy (Multi-Stage Build) #

Building SPA static files directly inside the production server is inefficient and slows down the deployment process. The best way is using Docker containers with the Multi-Stage Build technique.

In Stage 1 you use a Node.js image to install dependencies and compile source code into static HTML/JS/CSS files. In Stage 2, you move those build result files into a minimal, memory-saving Caddy Alpine image.

Here’s the recommended production Dockerfile structure:

# Stage 1: Node.js Compilation Pipeline
FROM node:20-alpine AS builder
WORKDIR /app

# Copy the package manager files and install dependencies
COPY package*.json ./
RUN npm ci

# Copy the entire project source code and run the build
COPY . .
RUN npm run build

# Stage 2: Serve the static files using Minimal Caddy
FROM caddy:2.8.4-alpine
WORKDIR /usr/share/caddy

# Copy our custom Caddyfile configuration file into the image
COPY Caddyfile /etc/caddy/Caddyfile

# Copy the compilation result folder (dist) from Stage 1
COPY --from=builder /app/dist /usr/share/caddy

# Expose standard web ports
EXPOSE 80 443

Here’s the Caddyfile file you copy into that Docker image:

# Caddyfile inside the Docker Container
:80 {
    # Set the container working folder
    root * /usr/share/caddy
    encode zstd gzip
    
    # Caching rules
    @hashed_assets path_regexp \.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|svg|ico)$
    header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
    
    @html path *.html /
    header @html Cache-Control "no-cache, no-store, must-revalidate"
    
    # SPA Fallback
    try_files {path} /index.html
    file_server
}

Handling Dynamic Environment Variables in SPAs #

Because SPA applications run entirely inside the user’s web browser (client side), browsers don’t have access to the Linux OS environment variables running on your backend web server. All environment variables must be injected statically during the compilation process (npm run build).

1. Injecting Variables at Build Time (Build-Time Injection) #

Vite or Create React App looks for variables with special prefixes during compilation:

# For Vite-based applications
VITE_API_URL="https://api.example.com" VITE_APP_ENV="production" npm run build

# For Create React App-based applications (old React)
REACT_APP_API_URL="https://api.example.com" npm run build

Inside your React application’s JavaScript code, you access those variables using:

const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:8080";

2. Injecting Dynamic Variables After Build (Runtime Injection) #

The challenge arises when you want to use the same Docker image for Staging and Production environments without recompiling (the build once, deploy anywhere principle).

The best solution is creating a custom configuration file named config.js inside your public folder, then loading that file using a <script> tag at the top of the index.html file:

<!-- index.html -->
<head>
    <!-- Load the dynamic config file from the web server -->
    <script src="/config.js"></script>
</head>

Inside the config.js file you place on the target server, you define global window variables:

// /var/www/myapp/dist/config.js
window.APP_CONFIG = {
    API_URL: "https://api-prod.example.com",
    VERSION: "1.0.0",
    ENABLE_REALTIME_CHAT: true
};

In the Caddyfile, you must forbid browsers from indexing or caching the config.js file so browsers always fetch the latest environment configuration when the web page opens:

# Forbid config.js caching
example.com {
    root * /var/www/myapp/dist
    
    @config path /config.js
    header @config Cache-Control "no-cache, no-store, must-revalidate"
    
    try_files {path} /index.html
    file_server
}

Preview Deployments for Pull Requests via Wildcard Subdomains #

When your developer team creates a Pull Request (PR) on GitHub, the modern industry best practice is providing an automatic preview deployment link for quality review teams (QA) to interactively test new features (e.g., pr-123.preview.example.com).

You can combine Caddy’s Wildcard Subdomain DNS Challenge feature with dynamic folder path mapping to serve dozens of pre-built preview folders automatically without repeatedly editing your Caddyfile:

# Automatic configuration for Pull Request previews
*.preview.example.com {
    # Must use the DNS Challenge for wildcard domains
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    
    # 1. Extract the PR number from the subdomain using a host regex query.
    # Takes the number from host "pr-123.preview.example.com" to "123"
    map {host} {pr_number} {
        ~^pr-(\d+)\.preview\. $1
        default ""
    }
    
    # 2. Define the dynamic working folder based on the PR number
    handle {
        root * /var/www/previews/pr-{pr_number}
        
        # SPA Fallback routing
        try_files {path} /index.html
        file_server
    }
}

In your GitHub Actions CI/CD pipeline, every time there’s a push on a PR branch, the CI/CD runner just compiles the code and puts its dist folder on the target server at the path:

/var/www/previews/pr-123

Caddy instantly serves it on the related subdomain URL dynamically under official Cloudflare HTTPS certificate protection.


Handling Real 404s on Static Assets #

Applying the crude try_files {path} /index.html directive has a confusing side effect for monitoring systems. If the browser loads a missing static image file (e.g., loading a misspelled logo image /assets/logo-wrong.png), Caddy doesn’t return a 404 status, but returns the index.html HTML file with HTTP 200 status. The browser tries parsing that HTML file as an image, triggering console errors in the browser and polluting system logs.

The best solution is separating static asset handling. If the searched static file doesn’t exist on disk, Caddy must immediately return a real 404 Not Found status:

# Separating real static asset 404s from the SPA fallback
app.example.com {
    root * /var/www/myapp/dist
    
    # Detect if the requested static file doesn't have a physical file on disk
    @static_miss {
        not file
        path *.js *.css *.png *.jpg *.woff2 *.ico *.svg
    }
    # Reject immediately with a real 404 status
    respond @static_miss "Static Asset Not Found!" 404
    
    # Safe fallback path specifically for SPA virtual routes
    try_files {path} /index.html
    file_server
}

Static File vs SPA Fallback Routing Evaluation Flow Diagram #

To visualize how Caddy processes and filters static requests, dynamic requests, and deep link route requests on your SPA application, look at the following flowchart:

flowchart TD
    A["Client Browser Request Arrives\n(e.g., GET /dashboard/profile)"] --> B["1. Caddy evaluates named matchers"]
    
    B --> C{"2. Is the request route\nan /api/* segment?"}
    
    C -- "Yes" --> D["3. Route the request to reverse_proxy\n(Forward directly to the API backend)"]
    D --> E["Done"]
    
    C -- "No" --> F{"4. Does the URL request a static file?\n(e.g. *.js, *.css, *.png)"}
    
    F -- "Yes" --> G{"5. Does the physical file\nexist on disk?"}
    G -- "Yes" --> H["6. Serve the static file\nwith aggressive Cache-Control headers"]
    G -- "No" --> I["7. Reject the request instantly\nand return HTTP 404 status"]
    H --> E
    I --> E
    
    F -- "No" --> J{"8. Does the referenced route\nexist on disk?"}
    J -- "Yes" --> K["9. Serve the related physical html page"]
    J -- "No" --> L["10. Trigger the try_files directive\nand return the main index.html file"]
    
    K --> E
    L --> M["11. The React/Vue Router in the browser reads the URL\nand renders the appropriate component"]
    M --> E

Summary #

  • SPA Routing Configuration: Use the try_files {path} /index.html directive to safely redirect all client virtual routes back to the browser’s main entry page.
  • Hashed Asset Cache Policy: Apply aggressive long-term browser caching (max-age=31536000, immutable) only to bundler files with unique hashes in their names.
  • index.html Cache Protection: Ensure the index.html file uses no-cache, no-store headers so browsers immediately know if there’s a build version update on the server side.
  • API Route Isolation: Wrap API routing (/api/*) inside standalone handle blocks so dynamic requests aren’t affected by the SPA try_files fallback rules.
  • Real 404 Handling: Protect system performance by instantly rejecting (respond 404) requests for image/JS extension static files that have no physical presence on disk.
  • Docker Container Cycle: Apply Docker Multi-Stage Builds using the Node.js builder image and the Caddy Alpine runtime for minimal container size results (~40MB).
  • CI/CD Automation: Combine Caddy’s wildcard subdomain DNS Challenge with PR number mapping to compose dynamic automatic PR preview systems.

← Previous: WebSocket   Next: API Gateway →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact