Melayani aset statis dengan tower-http ServeDir dan ServeFile: index fallback untuk SPA, cache headers, kompresi, dan pola peringatan saat static files tumbuh besar.

API kalian sudah lengkap — tapi banyak aplikasi nyata butuh lebih: melayani frontend statis (HTML, JS, CSS, gambar) dari server yang sama. Axum menangani ini tanpa server web tambahan, lewat ServeDir dan ServeFile dari tower-http.
Episode ini membahas kapan static files sebaiknya dilayani Axum (vs reverse proxy), cara memasang ServeDir dengan fallback untuk SPA, mengatur cache headers dengan benar, dan menggabungkannya dengan compression dari episode 8. Ini jembatan menuju episode 20 (HTTPS & reverse proxy) dan 24 (deployment).
Jawaban jujurnya: tidak selalu. Ada tiga pilihan umum:
| Opsi | Kelebihan | Kekurangan |
|---|---|---|
Axum via ServeDir | Satu deploy, satu domain, kode seragam | Kurang optimal untuk aset besar |
| Reverse proxy (nginx/caddy) | Performa tinggi, cache canggih, terpisah | Infrastruktur tambahan |
| CDN / object storage | Cache edge, hemat server | Tambahan biaya & tooling |
Aturan praktis:
ServeDir sudah cukup.index.html untuk path HTML5 history.Episode ini mengajarkan opsi pertama dengan benar — dan kalian akan tahu kapan harus pindah ke opsi lain.
Tambah fitur fs di tower-http:
cargo add tower-http --features fs,traceBuat folder aset dan file contoh:
mkdir -p static
echo '<h1>Beranda</h1>' > static/index.html
echo 'body { color: #333; }' > static/style.cssPasang ServeDir sebagai fallback route:
use axum::{Router, routing::get};
use tower_http::services::ServeDir;
fn app() -> Router {
Router::new()
.route("/api/health", get(|| async { "ok" }))
.fallback_service(ServeDir::new("static"))
}fallback_service berarti: semua request yang tidak cocok dengan route API akan dilayani dari folder static. Request GET /style.css akan membaca static/style.css secara langsung — dengan Content-Type yang otomatis benar (via mime_guess).
Warning
Perhatikan: fallback (biasa) hanya menerima handler; fallback_service menerima Service. Untuk ServeDir, gunakan fallback_service. Jangan mencampur keduanya — ini error umum saat menambahkan static files.
Aplikasi SPA (React/Vue/Svelte) memakai HTML5 history API — URL seperti /tentang harus mengembalikan index.html, bukan 404, karena routing dilakukan JavaScript di browser. Pola ini memakai ServeDir + ServeFile:
use tower_http::services::{ServeDir, ServeFile};
fn spa_app() -> Router {
Router::new()
.route("/api/health", axum::routing::get(|| async { "ok" }))
.fallback_service(
ServeDir::new("static").not_found_service(ServeFile::new("static/index.html")),
)
}Alurnya: ServeDir mencoba file; jika tidak ditemukan (bukan karena path api/), ia menyerahkan ke ServeFile::new("static/index.html"). Efeknya: GET /tentang mengembalikan index.html → browser menjalankan JS → router client menampilkan halaman "Tentang".
Perlu disadari: pola ini membuat URL yang salah eja juga mengembalikan 200 (index.html). Ini trade-off SPA yang wajar; jika ingin 404 asli untuk aset hilang, pisahkan path aset (/assets/*) dari path halaman.
Ini bagian yang paling sering salah: cache headers menentukan seberapa cepat (dan hemat bandwidth) situs kalian. Dua kategori aset:
| Aset | Strategi | Contoh header |
|---|---|---|
File ber-hash (app.8f2a.js) | Cache abadi — nama berubah saat konten berubah | Cache-Control: public, max-age=31536000, immutable |
index.html | Tanpa cache atau sangat pendek — selalu ambil versi terbaru | Cache-Control: no-cache |
ServeDir mendukung Cache-Control per konfigurasi (via ServeDirLayer atau memakai fallback handler kustom). Pendekatan sederhana: middleware yang menambahkan header cache untuk file di /assets/:
use axum::{extract::Request, middleware::Next, response::Response};
use tower_http::services::ServeDir;
async fn set_cache_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
if request.uri().path().starts_with("/assets/") {
if let Ok(value) =
axum::http::HeaderValue::from_static("public, max-age=31536000, immutable")
{
response.headers_mut().insert("cache-control", value);
}
}
response
}
fn app() -> Router {
Router::new()
.route("/api/health", axum::routing::get(|| async { "ok" }))
.fallback_service(ServeDir::new("static"))
.layer(axum::middleware::from_fn(set_cache_headers))
}Aturan emasnya: file ber-hash → cache panjang; file tanpa hash → no-cache. Salah menerapkan immutable pada index.html adalah bug yang menyebalkan: pengguna tidak pernah melihat update.
Tip
Pastikan build tooling kalian menulis nama file ber-hash untuk aset (seperti main.4f3a.css). Tuliskan konvensi ini di README project — lebih murah daripada debugging "kenapa update tidak muncul" selama berhari-hari.
Compression dari episode 8 bekerja otomatis pada response ServeDir — karena kompresi adalah middleware yang membungkus semua service:
use tower_http::{compression::CompressionLayer, services::ServeDir};
fn app() -> Router {
Router::new()
.fallback_service(ServeDir::new("static"))
.layer(CompressionLayer::new())
}Hasilnya: file CSS/JS dikirim terkompresi (gzip/br) jika browser mendukung Accept-Encoding. Periksa dengan curl:
curl -s -o /dev/null -w "%{size_download} bytes\n" \
-H "Accept-Encoding: gzip" http://localhost:3000/style.cssAngka yang keluar jauh lebih kecil daripada ukuran file asli menandakan kompresi bekerja.
Jika hanya perlu melayani satu file statis (misal favicon.ico atau robots.txt) tanpa folder:
use tower_http::services::ServeFile;
fn app() -> Router {
Router::new()
.route_service("/favicon.ico", ServeFile::new("static/favicon.ico"))
.route_service("/robots.txt", ServeFile::new("static/robots.txt"))
}route_service dipakai untuk memasang service (bukan handler) langsung ke route — ServeFile adalah service, jadi ini pasangannya yang tepat.
ServeDir memuaskan untuk aset kecil, tapi ada batasannya:
Pola yang umum di produksi: Axum melayani API + index.html saja, sementara aset ber-hash ditaruh di CDN/object storage. Episode 20 dan 24 membahas arsitektur ini.
Pada episode 11 ini kalian telah melayani static files:
ServeDir via fallback_service untuk melayani seluruh folder.not_found_service(ServeFile::new("static/index.html")) untuk fallback SPA.immutable, index.html no-cache.Di episode 12 selanjutnya kita masuk mode real-time: WebSocket (dengan subprotocol) dan Server-Sent Events (SSE) — push data dari server ke klien untuk chat, notifikasi, dan dashboard langsung. Sampai jumpa di episode 12!