Start HereOrdered learning track

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.

8 min read1536 words
PrevNext
Lesson 18105 lesson track01–19 Start Here
#nginx#try-files#internal-redirect#spa+3 more

Part 018 — try_files, Internal Redirect, SPA Fallback, dan Multi-App Routing

try_files bukan “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:


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:

VariableContoh request /app.js?v=123Cocok untuk try_files?
$request_uri/app.js?v=123Tidak
$uri/app.jsYa
$argsv=123Tidak

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?

  1. Browser menerima status yang benar.
  2. CDN/cache tidak menyimpan fallback HTML sebagai JS.
  3. Observability missing asset jelas.
  4. 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:

  1. /index.html bisa match location lain.
  2. Headers/cache policy bisa berubah karena context baru.
  3. Jika /index.html juga fallback ke dirinya sendiri secara salah, loop internal bisa terjadi.
  4. 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:

  1. Fingerprinted assets boleh cache sangat lama.
  2. index.html jangan cache terlalu lama karena menunjuk manifest/asset baru.
  3. Missing asset harus 404, bukan fallback ke index.html.
  4. 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:

ApproachKelebihanRisiko
Banyak aliasTidak perlu mengubah layout build artifactButuh test internal redirect lebih banyak
Satu root mengikuti URLMapping sederhanaBuild/deploy harus menyusun artifact sesuai public namespace
Subdomain per appBoundary paling jelasDNS/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:

  1. NGINX melayani static assets langsung.
  2. App backend melayani dynamic routes.
  3. File yang tidak ditemukan dikirim ke app.

Risiko:

  1. Missing static file bisa jadi 200 dari app.
  2. App menerima banyak request noise untuk asset typo.
  3. 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.

KebutuhanMechanism
Deep route /dashboard tetap di address barInternal redirect via try_files
Canonical URL /docs ke /docs/External redirect return 301 /docs/
Missing asset menjadi not found=404
Dynamic app fallbackNamed 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:

  1. API ada di prefix terpisah atau host lain.
  2. Static host hanya menerima GET/HEAD.
  3. 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

SymptomLikely CauseDebug Path
Missing JS returns HTMLAsset location falls through to SPA fallbackAdd /assets/ { try_files $uri =404; }
/api/foo returns SPA shellMissing /api/ location or wrong prefixCheck nginx -T, location selection
Deep link returns 404No SPA fallbackAdd try_files $uri $uri/ /index.html in frontend location
Index cached too longCache header inherited from broad locationExact location for /index.html
Fallback loop/index.html fallback points to itself and file missingTest existence, use =500 for missing app shell
Multi-SPA serves wrong shellInternal redirect URI maps to wrong location/rootUse root-aligned layout or exact index locations
Asset 404 despite file existsroot/alias mapping wrongLog $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.

Lesson Recap

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.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.