try_files, Internal Redirects, and SPA Routing
Learn NGINX In Action - Part 018
Deep dive into try_files, internal redirects, named locations, SPA fallback routing, frontend/backend boundary design, and production-safe missing-file behavior.
Part 018 — try_files, Internal Redirect, SPA Fallback, dan Multi-App Routing
try_filesbukan “if file exists”. Ia adalah control-flow primitive. Ia mengecek kandidat file/directory berdasarkan current context, lalu jika tidak ada match, ia melakukan fallback ke status code, named location, atau internal redirect URI. Salah memahami ini membuat missing asset berubah jadi HTML 200, API error tertutup SPA, dan routing multi-app sulit diaudit.
Part ini membahas try_files dari first principles: apa yang diperiksa, kapan fallback terjadi, bagaimana internal redirect mengulang location selection, dan bagaimana mendesain routing SPA/MPA/multi-app yang aman.
Referensi resmi utama:
- NGINX core module directive
try_files: https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files - NGINX request processing: https://nginx.org/en/docs/http/request_processing.html
- NGINX static content guide: https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/
1. Mental model: try_files adalah branching table
Bentuk umum:
try_files file ... uri;
try_files file ... =code;
Secara konseptual:
for candidate in candidates_except_last:
build filesystem path in current root/alias context
if candidate exists:
serve it or continue directory/index handling
stop
if no candidate exists:
process last argument:
=404 -> return status code
@name -> jump to named location
/some/uri -> internal redirect to URI, re-run location selection
Diagram:
Kunci: fallback URI bukan path file langsung. Ia adalah URI internal yang melewati request processing lagi.
2. try_files $uri $uri/ =404
Baseline static server:
server {
listen 80;
server_name app.example.local;
root /srv/app/current/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Request:
GET /assets/app.js
Flow:
1. Check /srv/app/current/public/assets/app.js
2. If exists, serve it
3. If not, check directory /srv/app/current/public/assets/app.js/
4. If not, return 404
Request:
GET /docs/
Flow:
1. Check /srv/app/current/public/docs/
2. Directory exists
3. index module tries index.html because index index.html is configured
4. Serve /srv/app/current/public/docs/index.html if exists
$uri/ berarti “coba sebagai directory”. Ia bukan sekadar string tambahan.
3. $uri vs $request_uri
Jangan gunakan $request_uri di try_files.
# Bad idea
try_files $request_uri =404;
$request_uri berisi URI asli termasuk query string. try_files butuh file candidate, bukan raw request line.
Gunakan:
try_files $uri =404;
Perbedaan:
| Variable | Contoh request /app.js?v=123 | Cocok untuk try_files? |
|---|---|---|
$request_uri | /app.js?v=123 | Tidak |
$uri | /app.js | Ya |
$args | v=123 | Tidak |
Mental model:
$request_uri = apa yang client kirim
$uri = URI internal NGINX setelah normalisasi tertentu
4. Last argument: status code
Pattern paling deterministik:
location /assets/ {
root /srv/app/current/public;
try_files $uri =404;
}
Jika file tidak ada, return 404. Tidak ada internal redirect, tidak ada fallback HTML, tidak ada proxy.
Untuk static asset fingerprinted, ini ideal:
/assets/app.7d9a3f.js exists -> 200
/assets/app.old.js missing -> 404
Kenapa penting?
- Browser menerima status yang benar.
- CDN/cache tidak menyimpan fallback HTML sebagai JS.
- Observability missing asset jelas.
- Deploy bug terlihat cepat.
5. Last argument: internal redirect URI
SPA fallback umum:
location / {
try_files $uri $uri/ /index.html;
}
Jika /dashboard tidak ada sebagai file/directory, NGINX melakukan internal redirect ke:
/index.html
Lalu location selection berjalan lagi untuk /index.html.
Ini bukan sama dengan:
serve /srv/app/current/public/index.html directly from current location
Konsekuensi:
/index.htmlbisa match location lain.- Headers/cache policy bisa berubah karena context baru.
- Jika
/index.htmljuga fallback ke dirinya sendiri secara salah, loop internal bisa terjadi. - Logging tetap satu request, tapi flow internal bisa melewati beberapa location.
6. Last argument: named location
Named location cocok untuk fallback ke backend atau handler internal.
location / {
try_files $uri $uri/ @backend;
}
location @backend {
proxy_pass http://app_backend;
}
Flow:
1. Coba static file.
2. Coba directory.
3. Jika tidak ada, proxy ke app_backend.
Ini umum untuk aplikasi server-rendered atau legacy app.
Namun untuk SPA murni, named backend bukan fallback yang tepat jika semua route frontend harus ke index.html.
7. Internal redirect loop
Config buruk:
location / {
try_files $uri /index.html;
}
location = /index.html {
try_files $uri /index.html;
}
Jika /index.html tidak ada, fallback ke /index.html lagi. NGINX punya limit internal redirection, tetapi Anda tidak ingin bergantung pada limit itu.
Lebih aman:
location = /index.html {
root /srv/app/current/public;
try_files /index.html =500;
}
location / {
root /srv/app/current/public;
try_files $uri $uri/ /index.html;
}
Tapi biasanya tidak perlu location khusus untuk /index.html kecuali ingin cache policy berbeda.
8. SPA routing yang benar
Problem SPA:
GET /dashboard/settings
Tidak ada file fisik:
/srv/app/current/public/dashboard/settings
Namun route itu valid di client-side router. Maka fallback ke index.html memang benar.
Config minimal:
server {
listen 80;
server_name spa.example.local;
root /srv/spa/current/public;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
Tetapi config minimal belum production-safe.
9. SPA production-safe: pisahkan immutable assets dari app shell
Build SPA biasanya menghasilkan:
public/
├── index.html
├── assets/
│ ├── app.7d9a3f.js
│ ├── app.2c8b1e.css
│ └── logo.9f01a2.svg
└── favicon.ico
Policy yang benar:
- Fingerprinted assets boleh cache sangat lama.
index.htmljangan cache terlalu lama karena menunjuk manifest/asset baru.- Missing asset harus 404, bukan fallback ke
index.html. - Deep route frontend fallback ke
index.html.
Config:
server {
listen 80;
server_name spa.example.local;
root /srv/spa/current/public;
index index.html;
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff always;
}
location = /index.html {
try_files /index.html =500;
expires -1;
add_header Cache-Control "no-store" always;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Why:
/assets/missing.js -> 404
/dashboard/settings -> /index.html
/favicon.ico -> file if exists, otherwise /index.html unless explicitly handled
/index.html -> no-store
Untuk favicon/robots/manifest, biasanya lebih baik eksplisit:
location = /favicon.ico {
try_files /favicon.ico =404;
access_log off;
}
location = /robots.txt {
try_files /robots.txt =404;
access_log off;
}
location = /manifest.webmanifest {
try_files /manifest.webmanifest =404;
add_header Cache-Control "no-cache" always;
}
10. Anti-pattern: missing JS menjadi HTML 200
Config buruk:
location / {
try_files $uri $uri/ /index.html;
}
Tanpa location khusus assets, request:
GET /assets/app.old.js
Jika file tidak ada, response:
200 OK
Content-Type: text/html
<body>SPA index</body>
Browser lalu error:
Expected JavaScript module script but the server responded with a MIME type of "text/html".
Masalah sebenarnya bukan MIME. Masalahnya fallback salah.
Solusi:
location /assets/ {
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
11. API route tidak boleh jatuh ke SPA
Config buruk:
location / {
try_files $uri $uri/ /index.html;
}
Jika API ada di host yang sama:
/api/orders
Tanpa location /api/, request API typo bisa fallback ke HTML 200. Client melihat JSON parse error, bukan 404/502 yang jelas.
Production pattern:
location /api/ {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /assets/ {
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
Boundary rule:
API prefix harus didefinisikan sebelum fallback SPA secara konseptual.
Secara algoritma NGINX, prefix terpanjang /api/ akan menang dari /, tetapi secara review manusia tetap letakkan boundary route dengan jelas.
12. Multi-SPA di satu domain
Misal:
/admin/* -> admin SPA
/portal/* -> portal SPA
/ -> marketing site
/api/* -> API backend
Filesystem:
/srv/www/
├── marketing/public/index.html
├── admin/public/index.html
└── portal/public/index.html
Config dengan alias:
server {
listen 80;
server_name app.example.local;
location /api/ {
proxy_pass http://api_backend;
}
location /admin/assets/ {
alias /srv/www/admin/public/assets/;
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /admin/ {
alias /srv/www/admin/public/;
try_files $uri $uri/ /admin/index.html;
}
location = /admin/index.html {
alias /srv/www/admin/public/index.html;
add_header Cache-Control "no-store" always;
}
location /portal/assets/ {
alias /srv/www/portal/public/assets/;
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /portal/ {
alias /srv/www/portal/public/;
try_files $uri $uri/ /portal/index.html;
}
location = /portal/index.html {
alias /srv/www/portal/public/index.html;
add_header Cache-Control "no-store" always;
}
root /srv/www/marketing/public;
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}
Masalah config di atas: try_files fallback /admin/index.html melakukan internal redirect ke URI /admin/index.html, lalu harus ada location yang melayaninya dengan benar. Ini bisa bekerja, tapi harus dites.
Alternatif yang lebih bersih: build setiap SPA dengan base path dan gunakan subdomain:
admin.example.local
portal.example.local
www.example.local
Jika domain sama wajib, buat testing matrix ketat.
13. Multi-app dengan root layout yang disesuaikan
Agar menghindari banyak alias, layout filesystem bisa dibuat mengikuti URL:
/srv/www/root/
├── index.html
├── assets/
├── admin/
│ ├── index.html
│ └── assets/
└── portal/
├── index.html
└── assets/
Config:
server {
listen 80;
server_name app.example.local;
root /srv/www/root;
index index.html;
location /api/ {
proxy_pass http://api_backend;
}
location /admin/assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /admin/ {
try_files $uri $uri/ /admin/index.html;
}
location /portal/assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /portal/ {
try_files $uri $uri/ /portal/index.html;
}
location /assets/ {
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Ini lebih mudah diaudit karena fallback URI sesuai dengan filesystem namespace.
Trade-off:
| Approach | Kelebihan | Risiko |
|---|---|---|
Banyak alias | Tidak perlu mengubah layout build artifact | Butuh test internal redirect lebih banyak |
Satu root mengikuti URL | Mapping sederhana | Build/deploy harus menyusun artifact sesuai public namespace |
| Subdomain per app | Boundary paling jelas | DNS/TLS/session/cookie perlu desain tambahan |
14. try_files dan front controller pattern
Untuk aplikasi server-side seperti PHP legacy atau framework yang punya front controller:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
Flow:
/about -> /index.php? original query
/assets/app.css -> static file if exists
/missing-static.css -> /index.php unless protected by /assets/ location
Sama seperti SPA, static prefix harus dilindungi:
location /assets/ {
try_files $uri =404;
}
Jangan biarkan missing asset diproses front controller kecuali memang framework butuh begitu.
15. try_files dengan reverse proxy fallback
Pattern hybrid:
location / {
root /srv/app/public;
try_files $uri $uri/ @app;
}
location @app {
proxy_pass http://app_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Use case:
- NGINX melayani static assets langsung.
- App backend melayani dynamic routes.
- File yang tidak ditemukan dikirim ke app.
Risiko:
- Missing static file bisa jadi 200 dari app.
- App menerima banyak request noise untuk asset typo.
- Cache behavior bisa tercampur.
Lebih aman:
location /assets/ {
root /srv/app/public;
try_files $uri =404;
}
location / {
root /srv/app/public;
try_files $uri $uri/ @app;
}
16. Internal redirect vs external redirect
Internal redirect:
try_files $uri /index.html;
Client tetap melihat URL asli:
/dashboard
Nginx internal menggunakan /index.html sebagai handler.
External redirect:
return 302 /index.html;
Client diarahkan ke URL baru:
/index.html
Untuk SPA deep link, gunakan internal fallback, bukan external redirect.
| Kebutuhan | Mechanism |
|---|---|
Deep route /dashboard tetap di address bar | Internal redirect via try_files |
Canonical URL /docs ke /docs/ | External redirect return 301 /docs/ |
| Missing asset menjadi not found | =404 |
| Dynamic app fallback | Named location @app |
17. Method semantics: jangan fallback semua method ke SPA
Request browser normal untuk SPA route adalah GET. Tetapi jika ada request:
POST /dashboard/settings
Apakah harus mengembalikan index.html? Biasanya tidak.
Nginx try_files tidak otomatis membatasi method. Anda bisa menambahkan guard:
location / {
limit_except GET HEAD {
return 405;
}
try_files $uri $uri/ /index.html;
}
Namun limit_except punya aturan context dan interaksi sendiri. Alternatif lebih eksplisit dengan error_page/named location bisa digunakan, tetapi jangan membuat config lebih kompleks tanpa kebutuhan.
Untuk kebanyakan SPA host murni:
- API ada di prefix terpisah atau host lain.
- Static host hanya menerima
GET/HEAD. - Method lain return 405/404.
18. Cache policy dan try_files
Problem:
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Ini buruk karena /dashboard yang fallback ke index.html juga menerima cache immutable. Saat deploy baru, user bisa stuck di app shell lama.
Pisahkan cache policy:
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location = /index.html {
try_files /index.html =500;
add_header Cache-Control "no-store" always;
}
location / {
try_files $uri $uri/ /index.html;
}
Tetapi ingat: internal redirect ke /index.html akan menggunakan location = /index.html jika match. Maka cache header app shell berada di tempat yang benar.
19. Observability: log fallback secara eksplisit
Nginx tidak otomatis menulis “try_files fallback happened” di access log. Buat observability dengan variable dan map.
Contoh sederhana:
map $uri $is_spa_candidate {
default 1;
~^/assets/ 0;
~^/api/ 0;
}
log_format edge '$remote_addr host=$host method=$request_method '
'request="$request" status=$status uri=$uri '
'request_uri=$request_uri request_filename=$request_filename '
'spa_candidate=$is_spa_candidate';
Lebih kuat: gunakan header debug hanya di environment non-prod atau untuk IP tertentu.
add_header X-Debug-Request-Filename $request_filename always;
Jangan expose path filesystem di public production response.
20. Test matrix untuk SPA/static routing
Setiap release static host harus melewati test seperti ini:
base=http://localhost:8080
curl -i "$base/" # 200 HTML
curl -i "$base/index.html" # 200 HTML, no-store
curl -i "$base/dashboard/settings" # 200 HTML, no-store
curl -i "$base/assets/app.known.js" # 200 JS, immutable
curl -i "$base/assets/missing.js" # 404, not HTML 200
curl -i "$base/api/missing" # proxy/API status, not SPA HTML
curl -i "$base/.env" # 403/404
curl -i "$base/assets/.env" # 403/404
curl -I "$base/dashboard/settings" # HEAD works predictably
curl -X POST -i "$base/dashboard/settings" # 405/404 or intentional behavior
Automate with shell:
assert_status() {
expected="$1"
url="$2"
actual=$(curl -s -o /tmp/resp -w '%{http_code}' "$url")
if [ "$actual" != "$expected" ]; then
echo "FAIL $url expected=$expected actual=$actual"
cat /tmp/resp
exit 1
fi
}
assert_status 200 "$base/"
assert_status 200 "$base/dashboard/settings"
assert_status 404 "$base/assets/missing.js"
Untuk asset missing, test content type juga:
curl -sI "$base/assets/missing.js" | grep -i '^content-type:'
Expected bukan text/html dengan 200.
21. Lab: SPA safe fallback
Buat files:
sudo mkdir -p /srv/lab/spa/public/assets
cat <<'HTML' | sudo tee /srv/lab/spa/public/index.html
<!doctype html>
<html>
<head><title>SPA Lab</title></head>
<body><div id="app">SPA Shell</div></body>
</html>
HTML
echo 'console.log("app")' | sudo tee /srv/lab/spa/public/assets/app.123.js
Config:
server {
listen 8081;
server_name _;
root /srv/lab/spa/public;
index index.html;
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options nosniff always;
}
location = /index.html {
try_files /index.html =500;
add_header Cache-Control "no-store" always;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Test:
curl -i http://localhost:8081/
curl -i http://localhost:8081/dashboard/settings
curl -i http://localhost:8081/assets/app.123.js
curl -i http://localhost:8081/assets/missing.js
Expected:
/ -> 200 index.html
/dashboard/settings -> 200 index.html
/assets/app.123.js -> 200 JavaScript
/assets/missing.js -> 404
22. Lab: API boundary
Tambahkan mock backend:
python3 -m http.server 9000 --directory /tmp
Config:
upstream api_backend {
server 127.0.0.1:9000;
}
server {
listen 8082;
server_name _;
root /srv/lab/spa/public;
location /api/ {
proxy_pass http://api_backend;
proxy_set_header Host $host;
}
location /assets/ {
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
Test:
curl -i http://localhost:8082/api/not-found
curl -i http://localhost:8082/not-an-api-route
Expected:
/api/not-found -> backend response, not SPA fallback
/not-an-api-route -> SPA index.html
23. Failure model
| Symptom | Likely Cause | Debug Path |
|---|---|---|
| Missing JS returns HTML | Asset location falls through to SPA fallback | Add /assets/ { try_files $uri =404; } |
/api/foo returns SPA shell | Missing /api/ location or wrong prefix | Check nginx -T, location selection |
| Deep link returns 404 | No SPA fallback | Add try_files $uri $uri/ /index.html in frontend location |
| Index cached too long | Cache header inherited from broad location | Exact location for /index.html |
| Fallback loop | /index.html fallback points to itself and file missing | Test existence, use =500 for missing app shell |
| Multi-SPA serves wrong shell | Internal redirect URI maps to wrong location/root | Use root-aligned layout or exact index locations |
| Asset 404 despite file exists | root/alias mapping wrong | Log $request_filename |
24. Design rules
Keep these rules close during config design:
1. Static immutable assets: try_files $uri =404.
2. SPA deep routes: try_files $uri $uri/ /index.html.
3. API routes: explicit proxy location; never rely on SPA fallback.
4. App shell index.html: short/no cache.
5. Fingerprinted assets: long immutable cache.
6. Internal redirect URI re-runs location selection.
7. Named location is for controlled fallback to backend/handler.
8. Missing file should not silently become 200 unless it is a deliberate frontend route.
9. Test routing behavior as part of deployment.
25. Mental model akhir
try_files adalah sebuah decision tree kecil di dalam location. Ia tidak hanya mengecek file; ia menentukan control flow berikutnya.
Pikirkan setiap try_files seperti kontrak:
For this URL namespace:
- which filesystem candidates are valid?
- what happens when none exist?
- is fallback a status, internal URI, or named handler?
- can fallback accidentally cross into another app/API/security boundary?
- are cache headers still correct after internal redirect?
Kalau jawaban pertanyaan itu tidak jelas dari config, config belum production-grade.
26. Ringkasan
try_files adalah salah satu directive paling kuat dan paling sering disalahpahami di NGINX. Untuk static assets, gunakan fallback deterministik ke =404. Untuk SPA, fallback ke /index.html hanya pada namespace frontend, bukan API atau immutable assets. Untuk backend hybrid, gunakan named location agar fallback eksplisit.
Internal redirect berarti NGINX menjalankan location selection lagi. Karena itu, cache policy, headers, root/alias, dan security boundary bisa berubah setelah fallback. Engineer yang kuat tidak hanya bertanya “apakah route jalan”, tetapi “control flow apa yang terjadi ketika file tidak ada”.
Part berikutnya akan membahas MIME types, default_type, content sniffing, dan bagaimana kesalahan content type dapat berubah menjadi bug security maupun bug deployment yang sulit dibaca.
You just completed lesson 18 in start here. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.