Single Page Application (React/Vue) #

Pada era web modern, arsitektur aplikasi web bergeser secara masif dari Multi-Page Application (MPA) tradisional berbasis server menuju Single Page Application (SPA). Di dalam arsitektur SPA, kita membangun seluruh antarmuka menggunakan kerangka kerja (framework) JavaScript modern (seperti React, Vue, Angular, atau Svelte) yang dikompilasi menjadi satu set berkas statis (HTML, JS, CSS, dan gambar).

Karena berkas hasil kompilasi SPA murni merupakan aset statis statik, kita tidak membutuhkan runtime backend aktif (seperti Node.js) di server untuk melayaninya. Nginx adalah pilihan terbaik untuk menyajikan aset statik ini karena kecepatannya yang legendaris. Namun, mendeploy SPA di Nginx menyimpan tantangan teknis tersendiri, terutama terkait dengan penanganan Client-side Routing (Rute Sisi Klien). Di artikel ini, kita akan membahas solusi penanganan rute statik Nginx, taktik caching aset terkompilasi, pemanfaatan kompresi statis pre-compiled, serta menyusun konfigurasi siap pakai di produksi untuk deployment root maupun sub-path.

Alur Keputusan Routing SPA pada Nginx #

Nginx harus dikonfigurasi untuk menangani fallback routing. Jika berkas fisik tidak ditemukan di disk server, Nginx tidak boleh mengembalikan status 404, melainkan harus mengirimkan berkas index.html kembali ke browser klien agar router JavaScript (seperti React Router atau Vue Router) dapat mengambil alih rendering tampilan berdasarkan URL.

Berikut adalah diagram alur keputusan penanganan request SPA di Nginx:

flowchart TD
    Request["Klien Meminta URI: /dashboard"] --> TryFiles["Nginx try_files $uri $uri/ /index.html"]
    
    TryFiles --> CekFile{"1. Cek File Aktual di Disk?"}
    CekFile -->|"Ada (Misal: /dashboard.html)"| SendFile["Kirim File Tersebut"]
    
    CekFile -->|"Tidak Ada"| CekDir{"2. Cek Folder Aktual di Disk?"}
    CekDir -->|"Ada (Misal: /dashboard/)"| SendDir["Sajikan Index Folder"]
    
    CekDir -->|"Tidak Ada"| Fallback["3. Alihkan ke /index.html"]
    
    Fallback --> SendIndex["Kirim index.html ke Browser"]
    SendIndex --> ClientRoute["JS Router Browser Membaca URL '/dashboard'"]
    ClientRoute --> RenderView["Render Komponen Halaman Dashboard"]

    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;

Masalah Utama: Client-side Routing vs Server-side Routing #

Satu-satunya berkas HTML nyata di server SPA kita adalah index.html. Ketika klien pertama kali mengakses halaman utama situs kita (https://domain.com/), Nginx membaca berkas index.html dari disk dan mengirimkannya ke browser. Browser kemudian mengunduh berkas JavaScript beralur, mengeksekusinya, dan merender halaman utama.

Namun, jika pengguna mengklik menu profil di dalam web, router JavaScript akan mengubah alamat URL browser menjadi https://domain.com/profile menggunakan HTML5 History API. Langkah ini terjadi sepenuhnya di dalam memori browser tanpa melakukan request baru ke Nginx. Tampilan profil akan muncul seketika (client-side routing).

Masalah fatal muncul ketika pengguna menekan tombol Reload/Refresh saat berada di halaman profil tersebut, atau membagikan link /profile ke orang lain. Browser akan mengirimkan permintaan HTTP GET baru ke Nginx untuk mencari berkas fisik bernama /profile atau folder /profile/ di dalam server. Karena folder tersebut tidak nyata di disk server kita, Nginx secara default akan mengembalikan galat 404 Not Found.

Solusi try_files Nginx #

Kita mengatasi masalah 404 ini dengan menggunakan direktif try_files untuk memaksa Nginx menyajikan berkas index.html sebagai fallback terakhir:

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

    location / {
        # Uji keberadaan berkas fisik ($uri), lalu folder ($uri/)
        # Jika keduanya tidak ada, kembalikan berkas /index.html ke browser klien
        try_files $uri $uri/ /index.html;
    }
}

Browser menerima berkas index.html dengan kode respons HTTP 200 OK, memuat file JavaScript aplikasi kita, dan kemudian router JavaScript akan membaca URL /profile di address bar lalu menampilkan halaman profil secara otomatis.


Strategi Caching Aset Kompilasi Produksi (Vite/Webpack) #

Build tools modern (seperti Vite, Webpack, Rollup, atau esbuild) mengompilasi aset aplikasi kita dengan menambahkan hash unik berdasarkan konten berkas ke dalam nama filenya (misalnya index-a8f9c2d1.js atau main-3b82d4.css).

Jika isi kode JavaScript kita berubah sedikit saja, nama berkas yang dihasilkan pada build berikutnya dijamin akan berubah. Karakteristik ini memungkinkan kita untuk menyusun strategi caching yang sangat agresif demi performa loading situs yang instan bagi pengguna setia kita.

1. Caching Agresif Berumur Panjang (Hashed Assets) #

Karena nama berkas ber-hash bersifat unik dan permanen (immutable), browser klien tidak perlu memeriksa perubahan berkas ini ke server kita selama masa berlaku cache belum habis. Kita menyetel cache ini selama 1 tahun:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2|webp)$ {
    # Ambil berkas langsung dari disk, jika tidak ada kembalikan 404
    try_files $uri =404;

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

2. Larangan Caching untuk index.html #

Satu-satunya berkas yang tidak boleh di-cache sama sekali adalah index.html. Berkas ini berisi tag referensi ke nama file JavaScript terkompilasi terbaru kita. Jika index.html disimpan oleh cache browser klien, klien tersebut akan terus memuat file JavaScript versi lama meskipun kita telah melakukan deployment fitur baru di server.

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

    # Cegah mutlak penyimpanan berkas index.html di cache browser/CDN
    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}

Memanfaatkan Kompresi Statis (Pre-compiled Compression) #

Mengaktifkan kompresi Gzip atau Brotli dinamis memaksa Nginx untuk memproses kompresi berkas JavaScript berukuran besar di CPU worker on-the-fly pada setiap permintaan masuk. Hal ini membuang banyak resource CPU server kita secara sia-sia.

Pola optimasi terbaik untuk SPA adalah melakukan kompresi statis saat proses build aplikasi di pipeline CI/CD kita. Kita mengonfigurasi build tools (seperti menggunakan plugin vite-plugin-compression) untuk otomatis menghasilkan file versi terkompresi .gz dan .br secara bersamaan (misalnya menghasilkan main.js, main.js.gz, dan main.js.br sekaligus).

Nginx tinggal mendeteksi keberadaan file pre-compressed tersebut di disk dan menyajikannya secara zero-copy ke klien tanpa membebani CPU server kita sama sekali.

http {
    # Aktifkan penyajian berkas pra-kompresi statis
    gzip_static on;
    brotli_static on; # Membutuhkan modul ngx_brotli

    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";
        }
    }
}

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

Terkadang kita perlu mendeploy aplikasi SPA tidak di domain utama (/), melainkan di bawah sub-path URL tertentu (misalnya https://company.com/portal/).

Jika skenario ini diterapkan, kita harus memastikan konfigurasi Nginx dan konfigurasi router aplikasi kita sinkron merujuk pada sub-path /portal/.

1. Konfigurasi di Sisi SPA (Contoh: Vite & React Router) #

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

2. Konfigurasi di Sisi Nginx #

Kita menyusun location block khusus /portal dan mengarahkan fallback rute ke /portal/index.html:

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

    # Rute khusus untuk aplikasi portal kita
    location ^~ /portal/ {
        alias /var/www/my-spa-app/portal-dist/;
        
        # Fallback routing harus dibatasi di dalam sub-path portal
        try_files $uri $uri/ /portal/index.html;

        # Header anti-cache untuk index.html portal
        add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
    }

    # Offload asset statik portal
    location ~* ^/portal/.*\.css|js|png|jpg|jpeg|gif|ico|svg|woff2|webp$ {
        # Tunjuk lokasi fisik alias
        alias /var/www/my-spa-app/portal-dist/;
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Contoh Konfigurasi Server Block Produksi Lengkap (Vite/React) #

Berikut adalah konfigurasi server block HTTPS tingkat produksi lengkap untuk mendeploy SPA modern (seperti Vite/React) dengan optimalisasi HTTPS, HSTS, HTTP/2, caching, dan kompresi statik:

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;

    # Penyetelan Direktori Build (Output dist dari build Vite/React)
    root /var/www/my-react-app/dist;
    index index.html;

    # Konfigurasi Keamanan Sertifikat SSL Let's Encrypt
    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;

    # Pengerasan Security Headers
    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;

    # Aktifkan Kompresi Statis
    gzip_static on;
    brotli_static on;

    # 1. Caching Agresif untuk Aset Hashed (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. Caching Sedang untuk File Statik Bawaan (public/ folder seperti 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. Penanganan Rute Utama SPA & Anti-Cache index.html
    location / {
        # Redirect seluruh rute yang tidak cocok ke index.html
        try_files $uri $uri/ /index.html;

        # Pastikan index.html tidak pernah di-cache browser
        add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
        
        # Sertakan kembali security headers untuk index.html
        add_header X-Frame-Options "DENY" always;
        add_header X-Content-Type-Options "nosniff" always;
    }

    # Blokir akses ke file konfigurasi tersembunyi (.git, .env)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Deployment Zero-Downtime & Blue-Green pada SPA (Mencegah Chunk Load Error) #

Ketika kita melakukan kompilasi ulang aplikasi SPA (misalnya menjalankan npm run build), build tool akan membuang berkas lama dan menciptakan berkas baru dengan nama hash yang berbeda.

Jika proses deployment kita lakukan dengan cara menghapus isi folder /dist/ lama secara langsung dan menggantinya dengan berkas /dist/ baru, pengguna yang sedang membuka aplikasi kita pada saat rilis berlangsung akan mengalami galat fatal “Chunk Load Error” (koneksi terputus ketika browser mencoba memuat modul JS lama yang sudah dihapus dari server).

Untuk mengatasi isu ini dan memastikan transisi pembaruan frontend berjalan lancar tanpa memutus sesi pengguna (Zero-Downtime Frontend Deployment), kita dapat menerapkan strategi Blue-Green Deployment sederhana di tingkat folder menggunakan tautan simbolis (symbolic link / symlink):

Langkah 1: Struktur Folder Deployment #

Alih-alih menaruh file build langsung di folder root target, kita menyusun struktur folder dengan dua direktori versi terpisah:

/var/www/my-spa-app/
├── releases/
│   ├── release_v1.0.0/  (Folder build lama)
│   └── release_v1.1.0/  (Folder build baru)
└── current -> /var/www/my-spa-app/releases/release_v1.0.0 (Symlink aktif)

Konfigurasikan direktif root di Nginx agar menunjuk ke tautan simbolis /current:

server {
    listen 80;
    server_name app.unisbadri.com;
    
    # Nginx akan mengikuti symlink 'current' menuju rilis aktif
    root /var/www/my-spa-app/current;
    index index.html;

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

    # Penanganan asset statik hashed
    location /assets/ {
        # Gunakan root langsung
        root /var/www/my-spa-app/current;
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Langkah 3: Eksekusi Peralihan Rilis secara Instan #

Ketika tim mendeploy versi baru (misal release_v1.1.0), kita melakukan kompilasi di folder terpisah, lalu memindahkan atau memperbarui symlink current secara atomik menggunakan perintah terminal berikut:

# Buat symlink baru sementara
ln -sfn /var/www/my-spa-app/releases/release_v1.1.0 /var/www/my-spa-app/current_temp

# Lakukan rename secara atomik untuk menimpa symlink lama
mv -Tf /var/www/my-spa-app/current_temp /var/www/my-spa-app/current

Mengapa Cara Ini Aman? #

  1. Peralihan Atomik: Perintah mv -Tf dieksekusi di tingkat sistem operasi dalam hitungan milidetik secara instan. Tidak ada momen di mana Nginx kehilangan akses ke berkas HTML indeks.
  2. Retensi Berkas Lama: Berkas JS/CSS lama dari release_v1.0.0 masih tetap ada di disk. Jika ada pengguna lama yang masih membuka browser dan memuat chunk lama, Nginx dapat melayaninya secara normal dari folder rilis sebelumnya yang masih dipertahankan di folder releases/ (direkomendasikan menyimpan 3-5 rilis terakhir sebelum dibersihkan).
  3. Kemudahan Rollback: Selain meminimalkan error unduhan chunk, dengan memisahkan folder rilis berdasarkan versi seperti ini, kita memiliki kemampuan untuk melakukan rollback rilis frontend secara instan. Jika versi v1.1.0 ternyata memiliki bug kritis di sisi klien, kita cukup mengarahkan kembali symlink current ke folder release_v1.0.0 dan memperbarui symlink tersebut tanpa perlu mengompilasi ulang kode yang memakan waktu lama.

Ringkasan dan Praktik Terbaik #

  • Gunakan try_files Secara Tepat: Pastikan fallback diakhiri dengan /index.html (menggunakan leading slash). Salah menulis path file fallback akan memicu kegagalan internal redirect loop di server Nginx.
  • Pisahkan Lokasi /assets/: Build tools modern meletakkan berkas ber-hash di dalam folder /assets/. Manfaatkan kejelasan path ini untuk menyusun caching agresif yang rapi.
  • Aktifkan gzip_static: pre-compress aset JS/CSS kita saat build pipeline dijalankan untuk memotong beban CPU server produksi Nginx saat melayani jutaan request unduhan aset.
  • Gunakan Cache-Control no-store untuk index.html: Jangan pernah melewatkan penonaktifan cache pada berkas indeks utama guna mencegah browser klien terjebak pada kode frontend versi lama setelah proses deployment baru selesai.

← Sebelumnya: Python WSGI/ASGI   Berikutnya: WebSocket Proxying →

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