SPA React/Vue/Angular

SPA React/Vue/Angular #

Single Page Application (SPA) seperti React, Vue, dan Angular mengelola routing di sisi client — browser menangani navigasi tanpa reload halaman. Ini menciptakan kebutuhan khusus di sisi server: semua route yang tidak mengarah ke file fisik harus dikembalikan ke index.html agar React Router, Vue Router, atau Angular Router bisa mengambil alih.

Caddy sangat cocok untuk serve SPA karena konfigurasinya sangat simpel dan performanya sangat baik untuk static files.

Konsep: Client-Side Routing #

Masalah tanpa konfigurasi khusus:
  User di /products/123 → refresh browser
  Caddy cari file /products/123 → TIDAK ADA
  Caddy return 404 ❌

Solusi: semua route → index.html
  User di /products/123 → refresh browser
  Caddy tidak temukan file → serve index.html
  React Router baca URL → render komponen Products ✓

Konfigurasi Dasar SPA #

app.example.com {
    root * /var/www/myapp/dist
    
    # try_files: coba file asli, jika tidak ada → index.html
    try_files {path} /index.html
    
    file_server
}

Hanya 4 baris — ini cukup untuk semua SPA framework.


Konfigurasi Lengkap dengan Optimasi Caching #

app.example.com {
    root * /var/www/myapp/dist
    
    encode gzip zstd
    
    # 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
    }
    
    # Aset statis dengan hash di nama file (dari build tool):
    # main.a1b2c3d4.js, chunk.5e6f7g8h.css
    # Cache sangat lama karena nama file berubah setiap ada perubahan kode
    @hashed_assets path_regexp \.(js|css|woff2?|png|jpg|svg|ico)$
    header @hashed_assets Cache-Control "public, max-age=31536000, immutable"
    
    # index.html: JANGAN di-cache
    # Browser harus selalu mengambil versi terbaru
    @html path *.html /
    header @html Cache-Control "no-cache, no-store, must-revalidate"
    header @html Pragma "no-cache"
    header @html Expires "0"
    
    # Service worker: cache singkat
    @sw path /service-worker.js /sw.js
    header @sw Cache-Control "no-cache"
    
    # Client-side routing: fallback ke index.html
    try_files {path} /index.html
    
    file_server
}

SPA + Backend API di Domain yang Sama #

example.com {
    root * /var/www/myapp/dist
    
    encode gzip zstd
    
    # API requests ke backend
    handle /api/* {
        reverse_proxy api-backend:8080 {
            header_up X-Real-IP {remote_host}
            header_up X-Forwarded-Proto {scheme}
        }
    }
    
    # WebSocket (jika perlu)
    handle /ws/* {
        reverse_proxy ws-backend:8080
    }
    
    # Semua request lain: SPA
    handle {
        try_files {path} /index.html
        file_server
    }
}

Build dan Deploy React/Vue #

# Build React app
cd /var/www/myapp-source
npm ci
npm run build
# Output di: dist/ atau build/

# Deploy ke server
rsync -av --delete dist/ user@server:/var/www/myapp/dist/

# Atau dengan CI/CD (GitHub Actions)
# .github/workflows/deploy.yml
name: Deploy SPA

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install & Build
        run: |
          npm ci
          npm run build          
        env:
          VITE_API_URL: https://api.example.com
          REACT_APP_API_URL: https://api.example.com
      
      - name: Deploy to server
        uses: burnett01/[email protected]
        with:
          switches: -avzr --delete
          path: dist/
          remote_path: /var/www/myapp/dist/
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: deploy
          remote_key: ${{ secrets.SSH_KEY }}
      
      - name: Health check
        run: |
          sleep 5
          curl -f https://example.com/ || exit 1          

Multi-Environment dengan Caddy #

# Production
app.example.com {
    root * /var/www/myapp/dist
    
    header Content-Security-Policy "default-src 'self' https://api.example.com"
    
    try_files {path} /index.html
    file_server
}

# Staging (dilindungi basicauth)
staging.example.com {
    basicauth {
        dev $2a$14$devHashHere
        qa  $2a$14$qaHashHere
    }
    
    root * /var/www/myapp-staging/dist
    
    # Header anti-indexing untuk staging
    header X-Robots-Tag "noindex, nofollow"
    
    try_files {path} /index.html
    file_server
}

Handling Environment Variables di SPA #

SPA berjalan di browser, jadi environment variables harus di-inject saat build:

# React (create-react-app): variabel dengan prefix REACT_APP_
REACT_APP_API_URL=https://api.example.com npm run build

# Vite: variabel dengan prefix VITE_
VITE_API_URL=https://api.example.com npm run build

# Angular: gunakan environment.ts
// Atau: inject config dinamis dari server
// public/config.js (di-generate saat deployment)
window.APP_CONFIG = {
    API_URL: "https://api.example.com",
    VERSION: "1.2.3",
    FEATURE_NEW_CHECKOUT: true
};
example.com {
    root * /var/www/myapp/dist
    
    # Jangan cache config.js
    @config path /config.js
    header @config Cache-Control "no-cache, no-store"
    
    try_files {path} /index.html
    file_server
}

Content Security Policy untuk SPA #

app.example.com {
    root * /var/www/myapp/dist
    
    header {
        # CSP untuk SPA modern (sesuaikan dengan kebutuhan)
        Content-Security-Policy "
            default-src 'self';
            script-src 'self' 'unsafe-inline';
            style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
            img-src 'self' data: https:;
            font-src 'self' https://fonts.gstatic.com;
            connect-src 'self' https://api.example.com wss://ws.example.com;
            frame-ancestors 'none';
        "
    }
    
    try_files {path} /index.html
    file_server
}

Preloading dan Performance Hints #

app.example.com {
    root * /var/www/myapp/dist
    
    encode gzip zstd
    
    # Push/preload resource hints (HTTP/2 Server Push sudah deprecated,
    # gunakan Link header sebagai gantinya)
    @html_request {
        path /
        method GET
    }
    header @html_request Link "</assets/main.js>; rel=preload; as=script"
    header @html_request Link "</assets/main.css>; rel=preload; as=style"
    
    try_files {path} /index.html
    file_server
}

Ringkasan #

  • try_files {path} /index.html adalah satu-satunya konfigurasi khusus yang diperlukan untuk SPA — semua route yang tidak ada filenya akan dikembalikan ke index.html.
  • Set Cache-Control: max-age=31536000, immutable untuk file dengan hash di nama (main.a1b2c3.js) dan no-cache untuk index.html agar deployment selalu mengambil versi terbaru.
  • Pisahkan /api/* dengan handle block agar API requests tidak kena fallback ke index.html.
  • Service worker (/service-worker.js) harus menggunakan Cache-Control: no-cache — versi lama service worker bisa menyebabkan user stuck di versi lama.
  • Untuk environment variables di SPA, gunakan prefix yang sesuai framework (REACT_APP_, VITE_) saat build, atau inject via file config.js yang di-generate server-side.
  • Gunakan encode gzip zstd — bundle JS/CSS modern bisa dikompresi 70-80%, membuat loading time jauh lebih cepat terutama untuk first load.

← Sebelumnya: WebSocket   Berikutnya: API Gateway →


Optimasi Lighthouse Score dengan Caddy #

Google Lighthouse mengukur beberapa aspek yang bisa dioptimasi di level Caddy:

app.example.com {
    root * /var/www/myapp/dist
    
    encode gzip zstd  # Lighthouse: reduce payload size
    
    header {
        # Lighthouse: HTTPS (otomatis oleh Caddy)
        Strict-Transport-Security "max-age=31536000"
        
        # Lighthouse: removes security vulnerabilities
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        
        # Lighthouse: proper cache policy untuk aset statis
        # (dikonfigurasi per-matcher di bawah)
    }
    
    # Long cache untuk versioned assets
    @versioned path_regexp \.[a-f0-9]{8,}\.(js|css|woff2?)$
    header @versioned Cache-Control "public, max-age=31536000, immutable"
    
    # No cache untuk HTML
    @html path *.html /
    header @html Cache-Control "no-cache"
    
    try_files {path} /index.html
    file_server
}

Preview Deployment untuk Pull Requests #

# Pattern: setiap PR mendapat subdomain preview
# pr-123.preview.example.com

*.preview.example.com {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    
    # Extract PR number dari subdomain
    map {host} {pr_number} {
        ~^pr-(\d+)\.preview\. $1
        default ""
    }
    
    handle {
        root * /var/www/previews/pr-{pr_number}
        try_files {path} /index.html
        file_server
    }
}
# Script deploy preview saat PR dibuat
# .github/workflows/preview.yml
# Deploy build ke /var/www/previews/pr-$PR_NUMBER/
# Caddy otomatis serve dari direktori yang sesuai

Menangani 404 yang Sesungguhnya #

Dengan try_files {path} /index.html, semua request — termasuk resource yang hilang — mengembalikan index.html dengan status 200. Untuk SEO dan analytics, mungkin kamu ingin membedakan 404 yang sesungguhnya:

app.example.com {
    root * /var/www/myapp/dist
    
    # Aset statis yang tidak ada: 404 sesungguhnya
    @static_miss {
        not file
        path *.js *.css *.png *.jpg *.woff2 *.ico *.svg
    }
    respond @static_miss 404
    
    # Route SPA: semua non-static → index.html
    try_files {path} /index.html
    file_server
}
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact