Serve Static
@miiajs/serve-static serves files from disk under a URL prefix. The package speaks full HTTP semantics: weak ETag and Last-Modified for cache revalidation, Range requests for resumable downloads and <video>/<audio> seeking, HEAD without body, and a SPA fallback gated by Accept and path extension. Default-deny dotfile policy keeps .env, .git/HEAD, and .well-known/... from leaking.
Installation
bun add @miiajs/serve-static
npm install @miiajs/serve-static
pnpm add @miiajs/serve-static
yarn add @miiajs/serve-static
Setup
Mount a directory under a URL prefix:
import { Miia } from '@miiajs/core'
import { serveStatic } from '@miiajs/serve-static'
const app = new Miia().register(AppModule)
serveStatic(app, '/static', './public')
This serves all files from ./public under the /static URL prefix:
GET /static/style.css -> ./public/style.css
GET /static/app.js -> ./public/app.js
GET /static/logo.png -> ./public/logo.png
Configuration options
serveStatic(app, '/assets', './dist', {
index: 'index.html',
maxAge: 3600,
etag: true,
dotfiles: false,
fallback: 'index.html',
})
| Option | Type | Default | Description |
|---|---|---|---|
index | string | false | 'index.html' | Index file for directory requests. Set to false to disable. |
maxAge | number | 0 | Cache-Control: max-age value in seconds. Applied to 200 and 304 responses. |
etag | boolean | true | Send ETag and Last-Modified and honor If-None-Match / If-Modified-Since. Disable to skip conditional GET. |
dotfiles | boolean | false | When false, any URL with a path segment starting with . returns 404 (e.g. .env, .git/HEAD, .well-known/...). |
fallback | string | false | false | Path inside root to serve when the requested file is missing and the request looks like a SPA navigation. Typically 'index.html'. |
getMimeType | (path: string) => string | built-in map | Custom MIME type resolver. Result is automatically suffixed with ; charset=utf-8 for text-like types unless it already specifies a charset. |
Usage
A typical SPA setup - serve built assets, fall back to index.html for client-side routes, cache hashed bundles aggressively:
import { Miia } from '@miiajs/core'
import { serveStatic } from '@miiajs/serve-static'
const app = new Miia().register(AppModule)
// Hashed assets - long-lived cache + SPA fallback for deep links.
serveStatic(app, '/', './dist', {
maxAge: 60 * 60 * 24 * 365,
fallback: 'index.html',
})
await app.listen(3000)
GET /about resolves to dist/index.html; GET /assets/app-abc123.js returns the bundle with a year-long cache; GET /favicon.ico returns the icon. Refresh on /about keeps client-side routing intact.
HTTP semantics
Conditional GET (304 Not Modified)
Each response carries a weak ETag of the form W/"<size>-<mtime>" and a Last-Modified header. Browsers and CDNs revalidate on subsequent requests:
> GET /assets/app.js
< 200 OK
< ETag: W/"4ad-194a8c4b800"
< Last-Modified: Mon, 12 May 2025 10:00:00 GMT
> GET /assets/app.js
> If-None-Match: W/"4ad-194a8c4b800"
< 304 Not Modified
If-None-Match takes precedence over If-Modified-Since per RFC 7232. Disable both with etag: false.
Range requests (206 Partial Content)
Range: bytes=0-99, bytes=900-, and suffix form bytes=-50 are supported. The server replies 206 with Content-Range: bytes start-end/total and streams only the requested slice. Out-of-range requests return 416 with Content-Range: bytes */total. Multi-range requests fall back to a full 200.
If-Range is honored against either the ETag or the Last-Modified value - if it doesn't match, the full file is served (200) so the client knows to discard its partial cache.
HEAD
HEAD requests reuse the GET pipeline (status code, all headers, including Content-Length and Content-Range for ranged HEAD) but return no body.
Charset
Text-like MIME types (text/*, application/javascript, application/json, application/xml, image/svg+xml) are returned with ; charset=utf-8 appended automatically. Binary types (images, fonts, archives) are not modified. If a custom getMimeType already specifies a charset, it's left alone.
SPA fallback
Single-page applications need any unmatched URL to resolve to index.html so client-side routing can take over after a page reload:
serveStatic(app, '/', './dist', {
fallback: 'index.html',
})
The fallback only triggers when:
- the request is
GETorHEAD Acceptincludestext/htmlor*/*(sofetch('/api/users')withAccept: application/jsonstill 404s correctly)- the path has no extension or ends with
.html/.htm(so a missing/assets/missing.png404s instead of returning HTML pretending to be an image)
The fallback path is served fresh each time with its own ETag, Last-Modified, and Content-Type.
Dotfiles policy
By default, any URL segment starting with . returns 404, so a stray .env next to your public/ directory or a .git/HEAD won't leak:
GET /static/.env -> 404
GET /static/.well-known/x -> 404
GET /static/foo/.git/HEAD -> 404
Set dotfiles: true to allow them (useful for .well-known/acme-challenge/... for ACME/HTTPS):
serveStatic(app, '/.well-known', './public/.well-known', { dotfiles: true })
public/legit.txt pointing to public/.env is not blocked - if you allow symlinks under public/, audit their targets.Custom MIME types
Override or extend MIME type detection with a custom getMimeType function:
import { serveStatic, getMimeType } from '@miiajs/serve-static'
serveStatic(app, '/assets', './public', {
getMimeType: (path) => {
if (path.endsWith('.glb')) return 'model/gltf-binary'
if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl'
return getMimeType(path)
}
})
The built-in getMimeType is exported for use as a fallback.
Advanced: raw handler
For custom routing, use createStaticHandler directly:
import { createStaticHandler } from '@miiajs/serve-static'
app.addRoute('GET', '/custom/*', createStaticHandler('./public', { maxAge: 3600 }))
Security
- Lexical traversal protection - paths normalized with
..segments outsiderootare rejected before any filesystem call. - Symlink escape protection -
realpath()resolves the target and re-checks it stays underroot. A symlink insidepublic/pointing outside is treated as 404. - Dotfile guard - on by default, see above.
- Fallback path validation - if you set
fallback: '.something'whiledotfilesis disabled,createStaticHandlerthrows at startup rather than serving a guard-bypassing fallback.
Supported MIME types
| Category | Extensions |
|---|---|
| Text | .html, .css, .txt, .csv, .xml, .yaml, .yml |
| JavaScript | .js, .mjs, .cjs, .wasm |
| Data | .json, .map, .pdf |
| Images | .svg, .png, .jpg, .jpeg, .gif, .webp, .avif, .ico |
| Fonts | .woff, .woff2, .ttf, .otf, .eot |
| Audio/Video | .mp3, .ogg, .wav, .mp4, .webm |
| Archives | .zip, .gz, .tar |
Unknown extensions default to application/octet-stream.
API Reference
serveStatic(app, prefix, root, options?)
Registers a wildcard GET ${prefix}/* route on app. Convenience wrapper for the common case.
serveStatic(app: Miia, prefix: string, root: string, options?: ServeStaticOptions): void
createStaticHandler(root, options?)
Returns a RequestContext-handler. Use when you need to mount the handler under a custom route, behind guards, or composed with middleware.
createStaticHandler(root: string, options?: ServeStaticOptions): (ctx: RequestContext) => Promise<Response>
getMimeType(path)
Built-in MIME resolver. Exported for use as a fallback inside a custom getMimeType.
getMimeType(path: string): string
Testing
Use a Miia instance directly - mount serveStatic against a temp directory, then drive it with app.fetch():
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Miia } from '@miiajs/core'
import { serveStatic } from '@miiajs/serve-static'
const root = mkdtempSync(join(tmpdir(), 'static-'))
writeFileSync(join(root, 'hello.txt'), 'hello world')
const app = new Miia({ logger: false })
serveStatic(app, '/static', root)
await app.init()
const res = await app.fetch(new Request('http://localhost/static/hello.txt'))
expect(res.status).toBe(200)
expect(await res.text()).toBe('hello world')
await app.destroy()
The same pattern works for asserting ETag round-trips, Range slicing, dotfile blocks, and SPA fallback - just toggle the relevant option and inspect headers on the response.
Exports
import {
serveStatic,
createStaticHandler,
getMimeType,
type ServeStaticOptions,
} from '@miiajs/serve-static'
See also
- Middleware - mounting
serveStaticbehind guards or custom middleware. @miiajs/node-server- the Node.js HTTP adapter that hosts the static handler.@miiajs/uws-server- the uWebSockets.js adapter for higher throughput.