Deliver Assets, Set Cache Headers, and Handle SPA Fallback in Koa
Serving Static Files
Use koa-static and koa-send to serve CSS, images, and JavaScript bundles with correct caching headers, and add an SPA fallback that returns index.html for unmatched routes.
What you'll learn
- Mount koa-static to serve a public directory with max-age caching
- Use koa-send to stream individual files with custom headers
- Implement an SPA catch-all fallback so client-side routing works correctly
Koa does not serve static files out of the box. The koa-static middleware
mounts a directory and handles ETag, Last-Modified, and Cache-Control
headers for you.
Installation
npm install koa-static koa-send Basic Static Middleware
import Koa from 'koa';
import serve from 'koa-static';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = new Koa();
app.use(
serve(path.join(__dirname, 'public'), {
maxAge: 60 * 60 * 24 * 7, // 7 days in seconds
gzip: true, // serve .gz pre-compressed files if present
br: true, // serve .br brotli-compressed files if present
})
); Place this middleware before your router so file requests short-circuit before hitting route logic.
Streaming a Single File with koa-send
Use koa-send when you need fine-grained control over a specific file — useful
for protected downloads or dynamic paths.
import send from 'koa-send';
import Router from '@koa/router';
const router = new Router();
router.get('/downloads/:name', async (ctx) => {
const dir = path.join(__dirname, 'protected');
await send(ctx, ctx.params.name, {
root: dir,
maxAge: 0, // no caching for protected files
setHeaders(res) {
res.setHeader('Content-Disposition', `attachment; filename="${ctx.params.name}"`);
},
});
}); Cache-Control Strategy
| Asset Type | Recommended max-age | Notes |
|---|---|---|
| Hashed bundles (app.abc123.js) | 31536000 (1 year) | Safe to cache forever — hash changes on update |
| HTML files | 0 or no-cache | Must stay fresh so new hashes are picked up |
| Images (versioned) | 604800 (1 week) | Balance freshness and bandwidth |
| API responses | 0 | Dynamic; set on the route, not static middleware |
SPA Fallback
When a single-page app handles routing in the browser, the server must return
index.html for any path that does not match a file or API route.
// Place AFTER the router and static middleware
app.use(async (ctx) => {
await send(ctx, 'index.html', { root: path.join(__dirname, 'public') });
}); Because koa-static calls next() when no file is found, the SPA fallback
only fires for unmatched requests — a correct and predictable cascade.
Up Next
Learn how to stream large files and binary data directly from Koa handlers without buffering the entire response in memory.
Streaming Responses →