Single Page Application (React/Vue) #

In the modern web era, web application architecture has massively shifted from traditional server-based Multi-Page Applications (MPA) towards Single Page Applications (SPA). In the SPA architecture, we build the entire interface using modern JavaScript frameworks (like React, Vue, Angular, or Svelte) that get compiled into a set of static files (HTML, JS, CSS, and images).

Because SPA compilation output files are purely static assets, we don’t need an active backend runtime (like Node.js) on the server to serve them. Nginx is the best choice for serving these static assets because of its legendary speed. However, deploying an SPA in Nginx holds its own technical challenges, especially related to handling Client-side Routing. In this article, we’ll discuss Nginx static route handling solutions, compiled asset caching tactics, utilizing pre-compiled static compression, and putting together ready-to-use production configurations for both root and sub-path deployments.

SPA Routing Decision Flow in Nginx #

Nginx must be configured to handle fallback routing. If a physical file isn’t found on the server disk, Nginx must not return a 404 status, but instead send the index.html file back to the client browser so the JavaScript router (like React Router or Vue Router) can take over rendering the view based on the URL.

Here’s a diagram of the SPA request handling decision flow in Nginx:

flowchart TD
    Request["Client Requests URI: /dashboard"] --> TryFiles["Nginx try_files $uri $uri/ /index.html"]
    
    TryFiles --> CekFile{"1. Check the Actual File on Disk?"}
    CekFile -->|"Exists (e.g.: /dashboard.html)"| SendFile["Send That File"]
    
    CekFile -->|"Doesn't Exist"| CekDir{"2. Check the Actual Folder on Disk?"}
    CekDir -->|"Exists (e.g.: /dashboard/)"| SendDir["Serve the Folder Index"]
    
    CekDir -->|"Doesn't Exist"| Fallback["3. Fallback to /index.html"]
    
    Fallback --> SendIndex["Send index.html to the Browser"]
    SendIndex --> ClientRoute["Browser JS Router Reads the URL '/dashboard'"]
    ClientRoute --> RenderView["Render the Dashboard Page Component"]

    classDef default fill:#f9f9f9,stroke:#d1d5db,stroke-width:1px,color:#111827;
    classDef nginxStyle fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e3a8a;
    classDef clientStyle fill:#f0fdf4,stroke:#15803d,stroke-width:2px,color:#166534;
    class TryFiles,CekFile,CekDir,Fallback nginxStyle;
    class ClientRoute,RenderView clientStyle;

The Main Problem: Client-side Routing vs Server-side Routing #

The only real HTML file on our SPA server is index.html. When a client first accesses our site’s main page (https://domain.com/), Nginx reads the index.html file from disk and sends it to the browser. The browser then downloads the bundled JavaScript file, executes it, and renders the main page.

However, if the user clicks the profile menu inside the web app, the JavaScript router changes the browser URL address to https://domain.com/profile using the HTML5 History API. This step happens entirely inside browser memory without making a new request to Nginx. The profile view appears instantly (client-side routing).

The fatal problem appears when the user presses the Reload/Refresh button while on that profile page, or shares the /profile link with someone else. The browser sends a new HTTP GET request to Nginx looking for a physical file named /profile or a /profile/ folder on the server. Because that folder isn’t real on our server disk, Nginx by default returns a 404 Not Found error.

The Nginx try_files Solution #

We solve this 404 problem by using the try_files directive to force Nginx to serve the index.html file as the final fallback:

server {
    listen 80;
    server_name app.unisbadri.com;
    root /var/www/my-spa-app/dist;

    location / {
        # Test for the existence of the physical file ($uri), then the folder ($uri/)
        # If neither exists, return the /index.html file to the client browser
        try_files $uri $uri/ /index.html;
    }
}

The browser receives the index.html file with an HTTP 200 OK response code, loads our application’s JavaScript file, and then the JavaScript router reads the /profile URL in the address bar and automatically shows the profile page.


Compiled Production Asset Caching Strategy (Vite/Webpack) #

Modern build tools (like Vite, Webpack, Rollup, or esbuild) compile our application assets by adding a unique hash based on the file content to the file name (e.g., index-a8f9c2d1.js or main-3b82d4.css).

If our JavaScript code content changes even slightly, the file name generated on the next build is guaranteed to change. This characteristic allows us to put together a very aggressive caching strategy for instant site loading performance for our loyal users.

1. Long-Lived Aggressive Caching (Hashed Assets) #

Because hashed file names are unique and permanent (immutable), client browsers don’t need to check file changes to our server as long as the cache validity hasn’t expired. We set this cache for 1 year:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|webp)$ {
    # Take the file directly from disk, return 404 if it doesn't exist
    try_files $uri =404;

    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

2. Caching Prohibition for index.html #

The only file that must never be cached is index.html. This file contains reference tags to our latest compiled JavaScript file names. If index.html is stored by the client browser cache, those clients will keep loading the old JavaScript file version even though we’ve deployed new features on the server.

location / {
    try_files $uri $uri/ /index.html;

    # Absolutely prevent storing the index.html file in the browser/CDN cache
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}

Utilizing Static (Pre-compiled) Compression #

Enabling dynamic Gzip or Brotli compression forces Nginx to process large JavaScript file compression on worker CPUs on-the-fly for every incoming request. This wastes a lot of our server CPU resources pointlessly.

The best optimization pattern for SPAs is doing static compression during the application build process in our CI/CD pipeline. We configure the build tools (like using the vite-plugin-compression plugin) to automatically generate the compressed .gz and .br file versions simultaneously (e.g., generating main.js, main.js.gz, and main.js.br at once).

Nginx just detects the existence of those pre-compressed files on disk and serves them zero-copy to clients without burdening our server CPU at all.

http {
    # Enable static pre-compressed file serving
    gzip_static on;
    brotli_static on; # Requires the ngx_brotli module

    server {
        listen 80;
        root /var/www/my-spa-app/dist;

        location ~* \.(js|css)$ {
            try_files $uri =404;
            expires 1y;
            add_header Cache-Control "public, immutable";
        }
    }
}

Sub-Path Deployment Scenario (Sub-Directory Deployment) #

Sometimes we need to deploy an SPA not on the main domain (/), but under a specific URL sub-path (e.g., https://company.com/portal/).

If this scenario is applied, we must make sure the Nginx configuration and our application router configuration synchronously refer to the /portal/ sub-path.

1. Configuration on the SPA Side (Example: Vite & React Router) #

  • Vite Config (vite.config.js):
    export default defineConfig({
      base: '/portal/', // Set the base URL for asset builds
    });
    
  • React Router:
    <BrowserRouter basename="/portal">
      <Routes>...</Routes>
    </BrowserRouter>
    

2. Configuration on the Nginx Side #

We put together a special /portal location block and point the route fallback to /portal/index.html:

server {
    listen 80;
    server_name app.unisbadri.com;
    root /var/www/my-spa-app; # Main root folder

    # Special route for our portal application
    location ^~ /portal/ {
        alias /var/www/my-spa-app/portal-dist/;
        
        # Fallback routing must be scoped inside the portal sub-path
        try_files $uri $uri/ /portal/index.html;

        # Anti-cache header for the portal index.html
        add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
    }

    # Portal static asset offloading
    location ~* ^/portal/.*\.css|js|png|jpg|jpeg|gif|ico|svg|woff2|webp$ {
        # Point to the physical alias location
        alias /var/www/my-spa-app/portal-dist/;
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Complete Production Server Block Configuration Example (Vite/React) #

Here’s a complete production-level HTTPS server block configuration for deploying a modern SPA (like Vite/React) with HTTPS, HSTS, HTTP/2, caching, and static compression optimization:

server {
    listen 80;
    listen [::]:80;
    server_name spa.unisbadri.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name spa.unisbadri.com;

    # Build Directory Setting (dist output from the Vite/React build)
    root /var/www/my-react-app/dist;
    index index.html;

    # Let's Encrypt SSL Certificate Security Configuration
    ssl_certificate /etc/letsencrypt/live/spa.unisbadri.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/spa.unisbadri.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    # Security Headers Hardening
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "no-referrer-when-downgrade" always;
    add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;

    # Enable Static Compression
    gzip_static on;
    brotli_static on;

    # 1. Aggressive Caching for Hashed Assets (Vite assets/)
    location /assets/ {
        alias /var/www/my-react-app/dist/assets/;
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # 2. Medium Caching for Built-in Static Files (public/ folder like favicon, logo)
    location ~* \.(ico|png|jpg|jpeg|gif|svg|webp|woff2)$ {
        try_files $uri =404;
        expires 30d;
        add_header Cache-Control "public, no-transform";
        access_log off;
    }

    # 3. Main SPA Route Handling & Anti-Cache index.html
    location / {
        # Redirect all non-matching routes to index.html
        try_files $uri $uri/ /index.html;

        # Make sure index.html is never cached by the browser
        add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
        
        # Re-include the security headers for index.html
        add_header X-Frame-Options "DENY" always;
        add_header X-Content-Type-Options "nosniff" always;
    }

    # Block access to hidden configuration files (.git, .env)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Zero-Downtime & Blue-Green SPA Deployment (Preventing Chunk Load Errors) #

When we recompile an SPA application (e.g., running npm run build), the build tool discards old files and creates new files with different hash names.

If our deployment process is done by directly deleting the old /dist/ folder contents and replacing them with the new /dist/ files, users currently using our application at release time will experience the fatal “Chunk Load Error” (connection cut when the browser tries to load an old JS module that’s already been deleted from the server).

To solve this issue and ensure the frontend update transition runs smoothly without cutting user sessions (Zero-Downtime Frontend Deployment), we can apply a simple Blue-Green Deployment strategy at the folder level using symbolic links (symlink):

Step 1: Deployment Folder Structure #

Instead of putting build files directly in the target root folder, we arrange the folder structure with two separate version directories:

/var/www/my-spa-app/
├── releases/
│   ├── release_v1.0.0/  (Old build folder)
│   └── release_v1.1.0/  (New build folder)
└── current -> /var/www/my-spa-app/releases/release_v1.0.0 (Active symlink)

Configure the root directive in Nginx to point to the /current symbolic link:

server {
    listen 80;
    server_name app.unisbadri.com;
    
    # Nginx follows the 'current' symlink to the active release
    root /var/www/my-spa-app/current;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # Hashed static asset handling
    location /assets/ {
        # Use the root directly
        root /var/www/my-spa-app/current;
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Step 3: Executing the Release Switch Instantly #

When the team deploys a new version (e.g., release_v1.1.0), we compile in a separate folder, then move or update the current symlink atomically using the following terminal commands:

# Create a temporary new symlink
ln -sfn /var/www/my-spa-app/releases/release_v1.1.0 /var/www/my-spa-app/current_temp

# Do an atomic rename to overwrite the old symlink
mv -Tf /var/www/my-spa-app/current_temp /var/www/my-spa-app/current

Why Is This Method Safe? #

  1. Atomic Switch: The mv -Tf command executes at the operating system level in milliseconds instantly. There’s no moment where Nginx loses access to the index HTML file.
  2. Old File Retention: The old JS/CSS files from release_v1.0.0 still remain on disk. If an old user still has their browser open and loads an old chunk, Nginx can serve it normally from the previous release folder that’s still retained in the releases/ folder (recommended to keep the last 3-5 releases before cleaning up).
  3. Easy Rollback: Besides minimizing chunk download errors, by separating release folders by version like this, we have the ability to instantly roll back frontend releases. If version v1.1.0 turns out to have critical client-side bugs, we just point the current symlink back to the release_v1.0.0 folder and update the symlink without needing to recompile code that takes a long time.

Summary and Best Practices #

  • Use try_files Correctly: Make sure the fallback ends with /index.html (using a leading slash). Writing the fallback file path wrong will trigger an internal redirect loop failure on the Nginx server.
  • Separate the /assets/ Location: Modern build tools put hashed files inside the /assets/ folder. Take advantage of this path clarity to arrange neat aggressive caching.
  • Enable gzip_static: Pre-compress our JS/CSS assets when the build pipeline runs to cut Nginx production server CPU load when serving millions of asset download requests.
  • Use Cache-Control no-store for index.html: Never skip disabling the cache on the main index file to prevent client browsers from getting stuck on the old frontend code version after a new deployment finishes.

← Previous: Python WSGI/ASGI   Next: WebSocket Proxying →

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