SPA React/Vue/Angular #
Menyajikan aplikasi satu halaman (Single Page Application / SPA) seperti React, Vue, Svelte, dan Angular merupakan salah satu tugas paling umum yang dijalankan oleh server web modern. Berbeda dengan aplikasi situs web tradisional yang memicu pemuatan ulang halaman penuh (server-side page load) untuk setiap link menu yang diklik, SPA memindahkan seluruh logika perutean (routing) ke sisi klien (client-side routing). Di browser pengguna, pustaka seperti React Router atau Vue Router menangani navigasi secara instan dengan memanipulasi riwayat URL browser menggunakan HTML5 History API tanpa menghubungi server web kita. Perilaku ini menciptakan tantangan teknis khusus di sisi server web edge: jika pengguna melakukan penyegaran halaman (refresh) atau mengakses URL rute dalam (deep link seperti /dashboard/users/settings) secara langsung, server web akan kebingungan mencari file fisik /dashboard/users/settings di dalam disk dan mengembalikan error 404 Not Found. Caddy mengatasi masalah ini secara elegan dan efisien menggunakan direktif try_files. Kita akan membahas konsep perutean sisi klien, mempraktikkan konfigurasi produksi optimal untuk kompresi dan manajemen caching agresif, mengintegrasikannya dengan backend API di satu domain, menyusun Dockerfile multi-stage build, menangani variabel lingkungan dinamis, hingga mengonfigurasi subdomain preview deployment otomatis untuk integrasi CI/CD.
Konsep: Client-Side Routing vs Server-Side Routing #
Untuk memahami mengapa konfigurasi khusus diperlukan untuk SPA, mari kita bandingkan perjalanan request pada kedua arsitektur perutean:
1. Skenario Server-Side Routing (Tradisional) #
Browser meminta halaman /about. Server mendeteksi folder atau berkas /about.html, merendernya, lalu mengembalikannya sebagai respons HTTP. Setiap kali pengguna berpindah halaman, browser mengosongkan layar dan memicu reload ulang seluruh aset CSS/JS.
2. Skenario Client-Side Routing (SPA) #
- Kunjungan Pertama: Browser mengunduh berkas index utama
index.htmlberserta bundle CSS dan JavaScript (misalnyamain.a1b2c3d4.js). - Interaksi Halaman: Pengguna mengklik tombol “Profil”. JavaScript mematikan request default browser, memperbarui URL di address bar menjadi
/profile, lalu merender komponen Profil secara instan menggunakan data RAM lokal. Tidak ada pemuatan ulang halaman. - Masalah Saat Refresh: Pengguna menyegarkan halaman browser (F5/refresh) saat berada di halaman
/profile. Browser langsung mengirimkan request HTTP GET/profileke Caddy. - Tanpa Konfigurasi Khusus: Caddy menelusuri folder web kita, tidak menemukan berkas bernama
/profileatau/profile.html, lalu mengembalikan status 404 Not Found. - Dengan Solusi try_files: Caddy tidak menemukan berkas
/profile, lalu secara cerdas mengembalikan isi dari berkas utama/index.htmldengan status HTTP 200. JavaScript SPA dimuat, mendeteksi URL aktif di address bar adalah/profile, lalu merender halaman profil secara otomatis.
Konfigurasi Dasar SPA #
Dalam Caddy, menyelesaikan isu perutean sisi klien ini sangatlah mudah. Kita hanya memerlukan satu baris direktif try_files di dalam berkas Caddyfile kita:
# Konfigurasi dasar untuk seluruh framework SPA (React, Vue, Svelte, Angular)
app.example.com {
# Tentukan folder build hasil kompilasi SPA kita
root * /var/www/myapp/dist
# Aturan fallback:
# 1. Coba cari file fisik yang sesuai dengan URL ({path}).
# 2. Jika tidak ada, arahkan request ke index.html utama.
try_files {path} /index.html
# Aktifkan server berkas statis
file_server
}
Hanya dengan konfigurasi empat baris di atas, seluruh rute dalam (deep links) pada aplikasi React/Vue kita akan berfungsi dengan sempurna saat diakses langsung atau direfresh oleh pengguna.
Konfigurasi Lengkap dengan Optimasi Caching #
Bundle JavaScript dan CSS hasil kompilasi aplikasi modern (Vite, Webpack) biasanya berukuran besar. Untuk memberikan skor performa terbaik pada uji Google Lighthouse dan mempercepat waktu muat halaman, kita harus mengaktifkan kompresi data tingkat tinggi serta menyusun kebijakan caching browser (caching policies) yang tepat.
Secara default, build tools modern menyisipkan hash nilai unik pada nama berkas yang diproduksi (misalnya index-a1b2c3d4.js dan style-5e6f7g8h.css). Jika kode aplikasi kita berubah, hash nama file tersebut akan ikut berubah saat di-build ulang. Ini membolehkan kita menerapkan cache jangka panjang yang agresif pada browser klien tanpa takut pengguna mendapatkan versi lama. Namun, berkas penala utama index.html dan berkas Service Worker dilarang keras untuk di-cache agar browser selalu mendeteksi jika ada update aplikasi terbaru.
Berikut adalah konfigurasi produksi SPA optimal di Caddyfile:
# Konfigurasi produksi SPA dengan optimasi performa Lighthouse
app.example.com {
root * /var/www/myapp/dist
# Aktifkan kompresi Zstd dan Gzip untuk bundle JS/CSS (mengurangi size hingga 80%)
encode zstd gzip
# Sisipkan header keamanan standar
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. Caching Agresif Selamanya (Hashed Assets):
# Seluruh berkas JS, CSS, font, dan gambar ber-hash di-cache selama 1 tahun.
# Kata kunci 'immutable' memberitahu browser untuk tidak pernah memvalidasi ulang file ini ke 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. Larangan Caching Berkas index.html:
# Berkas index.html harus selalu diambil versi terbarunya dari server
# agar browser dapat mendeteksi nama file JS ber-hash baru saat deploy.
@html path *.html /
header @html Cache-Control "no-cache, no-store, must-revalidate"
header @html Pragma "no-cache"
header @html Expires "0"
# 3. Larangan Caching Berkas Service Worker:
# Mengindeks Service Worker secara salah dapat menyebabkan browser pengguna
# terjebak di versi aplikasi lama selamanya.
@sw path /service-worker.js /sw.js
header @sw Cache-Control "no-cache, no-store, must-revalidate"
# 4. Terapkan perutean SPA fallback
try_files {path} /index.html
# Sajikan file statis
file_server
}
SPA + Backend API di Domain yang Sama #
Dalam beberapa kasus deployment, kita ingin menyajikan aplikasi frontend SPA dan aplikasi backend API kita di bawah satu nama domain yang sama (misalnya mengakses web di example.com dan request AJAX dikirim ke example.com/api/*).
Pendekatan ini memiliki keuntungan besar karena meniadakan kebutuhan konfigurasi CORS yang rumit di backend, serta menghindari masalah pemblokiran cookie pihak ketiga (third-party cookies).
Kita menggunakan blok handle untuk memisahkan segmentasi rute tersebut secara modular agar request API tidak terkena aturan fallback try_files SPA:
# Konfigurasi domain tunggal: SPA Frontend & API Backend
example.com {
root * /var/www/myapp/dist
encode zstd gzip
# 1. Rute API: Teruskan langsung ke server backend (Node.js/Python)
# Gunakan handle agar rute ini terisolasi dari proses file_server statis
handle /api/* {
reverse_proxy localhost:8080 {
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# 2. Rute WebSocket untuk real-time data
handle /ws/* {
reverse_proxy localhost:8080
}
# 3. Rute Default: Layani aplikasi frontend SPA
handle {
# Fallback rute dalam ke index.html
try_files {path} /index.html
file_server
}
}
Docker Containerization SPA dengan Caddy (Multi-Stage Build) #
Membangun berkas statis SPA secara langsung di dalam server produksi adalah tindakan yang kurang efisien dan memperlambat proses deployment. Cara terbaik adalah menggunakan wadah kontainer Docker dengan teknik Multi-Stage Build.
Pada Stage 1 kita menggunakan Node.js image untuk melakukan instalasi dependensi dan mengompilasi kode sumber menjadi berkas HTML/JS/CSS statis. Pada Stage 2, kita memindahkan berkas hasil build tersebut ke dalam image Caddy Alpine minimalis yang hemat ruang memori.
Berikut adalah susunan file Dockerfile produksi yang direkomendasikan:
# Stage 1: Pipa Kompilasi Node.js
FROM node:20-alpine AS builder
WORKDIR /app
# Salin berkas package manager dan instal dependensi
COPY package*.json ./
RUN npm ci
# Salin seluruh kode sumber proyek dan jalankan build
COPY . .
RUN npm run build
# Stage 2: Sajikan berkas statis menggunakan Caddy Minimalis
FROM caddy:2.8.4-alpine
WORKDIR /usr/share/caddy
# Salin berkas Caddyfile konfigurasi kustom kita ke dalam image
COPY Caddyfile /etc/caddy/Caddyfile
# Salin folder hasil kompilasi (dist) dari Stage 1
COPY --from=builder /app/dist /usr/share/caddy
# Ekspos port standar web
EXPOSE 80 443
Berikut adalah berkas Caddyfile yang kita salin ke dalam image Docker tersebut:
# Caddyfile di dalam Container Docker
:80 {
# Tentukan folder kerja kontainer
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
}
Penanganan Variabel Lingkungan Dinamis di SPA #
Karena aplikasi SPA berjalan sepenuhnya di dalam browser web pengguna (sisi klien), browser tidak memiliki akses ke variabel lingkungan (environment variables) sistem operasi Linux yang berjalan di server web backend kita. Seluruh variabel lingkungan harus disuntikkan secara statis saat proses kompilasi berjalan (npm run build).
1. Menyuntikkan Variabel Saat Build (Build-Time Injection) #
Vite atau Create React App mencari variabel dengan prefiks khusus saat kompilasi:
# Untuk aplikasi berbasis Vite
VITE_API_URL="https://api.example.com" VITE_APP_ENV="production" npm run build
# Untuk aplikasi berbasis Create React App (React lama)
REACT_APP_API_URL="https://api.example.com" npm run build
Di dalam kode JavaScript aplikasi React kita, kita mengakses variabel tersebut menggunakan:
const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:8080";
2. Menyuntikkan Variabel Dinamis Setelah Build (Runtime Injection) #
Tantangan muncul saat kita ingin menggunakan satu image Docker yang sama untuk lingkungan Staging dan Production tanpa perlu melakukan kompilasi ulang (prinsip build once, deploy anywhere).
Solusi terbaiknya adalah membuat berkas konfigurasi kustom bernama config.js di dalam folder public kita, lalu memuat berkas tersebut menggunakan tag <script> di bagian atas file index.html:
<!-- index.html -->
<head>
<!-- Muat file config dinamis dari server web -->
<script src="/config.js"></script>
</head>
Di dalam berkas config.js yang kita letakkan di server target, kita mendefinisikan variabel global window:
// /var/www/myapp/dist/config.js
window.APP_CONFIG = {
API_URL: "https://api-prod.example.com",
VERSION: "1.0.0",
ENABLE_REALTIME_CHAT: true
};
Pada Caddyfile, kita wajib melarang browser untuk mengindeks atau meng-cache berkas config.js agar browser selalu mengambil konfigurasi lingkungan terbaru saat halaman web dibuka:
# Larang caching config.js
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 Deployment untuk Pull Requests via Wildcard Subdomain #
Saat tim pengembang kita membuat Pull Request (PR) di GitHub, praktik terbaik industri modern adalah menyediakan tautan pratinjau otomatis (preview deployment) bagi tim penilai kualitas (QA) untuk menguji fitur baru secara interaktif (misalnya pr-123.preview.example.com).
Kita dapat memadukan fitur Wildcard Subdomain DNS Challenge Caddy dengan pemetaan jalur folder secara dinamis untuk menyajikan puluhan folder pratinjau pr-build secara otomatis tanpa menyunting Caddyfile kita berulang kali:
# Konfigurasi otomatis untuk pratinjau Pull Requests
*.preview.example.com {
# Wajib menggunakan DNS Challenge untuk domain wildcard
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# 1. Ekstrak nomor PR dari subdomain menggunakan kueri regex host.
# Mengambil nomor dari host "pr-123.preview.example.com" menjadi "123"
map {host} {pr_number} {
~^pr-(\d+)\.preview\. $1
default ""
}
# 2. Definisikan folder kerja dinamis berdasarkan nomor PR
handle {
root * /var/www/previews/pr-{pr_number}
# SPA Fallback routing
try_files {path} /index.html
file_server
}
}
Di dalam pipeline CI/CD GitHub Actions kita, setiap kali ada push di branch PR, runner CI/CD cukup mengompilasi kode dan meletakkan folder dist-nya ke dalam server target di jalur:
/var/www/previews/pr-123
Caddy akan secara instan menyajikannya pada URL subdomain terkait secara dinamis di bawah naungan sertifikat HTTPS resmi Cloudflare.
Menangani 404 yang Sesungguhnya pada Aset Statis #
Penerapan direktif kasar try_files {path} /index.html memiliki efek samping yang membingungkan bagi sistem monitoring. Jika browser memuat berkas gambar statis yang hilang (misalnya memuat gambar logo yang salah eja /assets/logo-wrong.png), Caddy tidak akan mengembalikan status 404, melainkan mengembalikan berkas HTML index.html dengan status HTTP 200. Browser akan mencoba mem-parsing berkas HTML tersebut sebagai gambar, memicu error console di browser, dan mengotori log sistem.
Solusi terbaiknya adalah memisahkan penanganan aset statis. Jika file statis yang dicari tidak ditemukan di disk, Caddy harus langsung mengembalikan status 404 Not Found yang sesungguhnya:
# Memisahkan 404 riil aset statis dengan fallback SPA
app.example.com {
root * /var/www/myapp/dist
# Deteksi jika berkas statis yang diminta tidak memiliki file fisik di disk
@static_miss {
not file
path *.js *.css *.png *.jpg *.woff2 *.ico *.svg
}
# Tolak langsung dengan status 404 sesungguhnya
respond @static_miss "Aset Statis Tidak Ditemukan!" 404
# Jalur fallback aman khusus untuk rute virtual SPA
try_files {path} /index.html
file_server
}
Diagram Alur Evaluasi File Statis vs SPA Fallback Routing #
Untuk memvisualisasikan bagaimana Caddy memproses dan menyaring request statis, request dinamis, dan request rute dalam (deep links) pada aplikasi SPA kita, perhatikan diagram flowchart berikut:
flowchart TD
A["Request Masuk Browser Klien\n(e.g., GET /dashboard/profile)"] --> B["1. Caddy mengevaluasi named matchers"]
B --> C{"2. Apakah rute request\nberupa segmentasi /api/*?"}
C -- "Ya" --> D["3. Salurkan request ke reverse_proxy\n(Teruskan langsung ke backend API)"]
D --> E["Selesai"]
C -- "Tidak" --> F{"4. Apakah URL meminta berkas statis?\n(e.g. *.js, *.css, *.png)"}
F -- "Ya" --> G{"5. Apakah berkas fisik\nada di disk?"}
G -- "Ya" --> H["6. Sajikan berkas statis\ndengan header Cache-Control agresif"]
G -- "Tidak" --> I["7. Tolak request secara instan\ndan kembalikan status HTTP 404"]
H --> E
I --> E
F -- "Tidak" --> J{"8. Apakah rute rujukan\nada di disk?"}
J -- "Ya" --> K["9. Sajikan halaman html fisik terkait"]
J -- "Tidak" --> L["10. Picu direktif try_files\ndan kembalikan berkas index.html utama"]
K --> E
L --> M["11. React/Vue Router di browser membaca URL\ndan merender komponen yang sesuai"]
M --> ERingkasan #
- Konfigurasi Routing SPA: Gunakan direktif
try_files {path} /index.htmluntuk membelokkan seluruh rute virtual klien kembali ke halaman penala utama browser secara aman.- Kebijakan Cache Hashed Assets: Terapkan caching browser agresif jangka panjang (
max-age=31536000, immutable) hanya untuk berkas bundler yang memiliki hash unik pada namanya.- Proteksi Cache index.html: Pastikan berkas
index.htmlmenggunakan headerno-cache, no-storeagar browser langsung mengetahui jika ada pembaruan versi build di sisi server.- Isolasi Rute API: Bungkus perutean API (
/api/*) di dalam blokhandlemandiri agar request dinamis tidak terkena imbas aturan fallbacktry_filesSPA.- Penanganan 404 Riil: Lindungi performa sistem dengan menolak secara instan (
respond 404) request berkas statis ber-ekstensi gambar/JS yang tidak ada fisiknya di disk.- Siklus Container Docker: Terapkan Docker Multi-Stage Build menggunakan image builder Node.js dan runtime Caddy Alpine untuk hasil container size minimalis (~40MB).
- Otomatisasi CI/CD: Gabungkan subdomain wildcard DNS Challenge Caddy dengan pemetaan PR number untuk menyusun sistem pratinjau otomatis PR secara dinamis.