sirv vs serve-static vs serve-handler: Static File Serving in Node.js (2026)
TL;DR
sirv is the lightweight static file server — fast, supports Brotli/gzip, SPA mode, used by SvelteKit and Vite's preview mode. serve-static is Express's static file middleware — the standard for Express apps, sendFile under the hood, well-tested. serve-handler is Vercel's static serving logic — powers the serve CLI, SPA rewrites, custom routing, cleanUrls. In 2026: sirv for Vite/SvelteKit and modern Node.js servers, serve-static for Express apps, serve-handler for serve-like deployment previews.
Key Takeaways
- sirv: ~5M weekly downloads — fast, Brotli/gzip, SPA mode, used by SvelteKit
- serve-static: ~10M weekly downloads — Express core, sendFile, ETags, ranges
- serve-handler: ~3M weekly downloads — Vercel, powers
serveCLI, cleanUrls, rewrites - All three serve static files but with different design philosophies
- sirv is the fastest — minimal overhead, precomputed responses
- serve-handler has the most routing features (rewrites, redirects, headers)
sirv
sirv — lightweight static server:
Basic usage
import sirv from "sirv"
import { createServer } from "node:http"
// Serve files from ./public:
const handler = sirv("public", {
maxAge: 31536000, // Cache for 1 year
immutable: true, // Immutable cache for hashed files
})
createServer(handler).listen(3000)
With polka or Express
import polka from "polka"
import sirv from "sirv"
polka()
.use(sirv("public", { dev: true })) // dev mode: no caching
.get("/api/health", (req, res) => {
res.end(JSON.stringify({ status: "ok" }))
})
.listen(3000)
// With Express:
import express from "express"
const app = express()
app.use(sirv("public", { maxAge: 86400 }))
app.get("/api/health", (req, res) => res.json({ status: "ok" }))
app.listen(3000)
SPA mode
import sirv from "sirv"
// SPA mode — falls back to index.html for unknown routes:
const handler = sirv("dist", {
single: true, // SPA mode
maxAge: 31536000,
immutable: true,
})
// /about → serves dist/index.html (SPA handles routing)
// /app.js → serves dist/app.js (static file)
// /styles.css → serves dist/styles.css (static file)
Brotli and gzip
import sirv from "sirv"
// sirv automatically serves .br and .gz files if they exist:
const handler = sirv("dist", {
brotli: true, // Serve .br files when Accept-Encoding: br
gzip: true, // Serve .gz files when Accept-Encoding: gzip
})
// If dist/app.js.br exists → serves compressed version
// If dist/app.js.gz exists → serves gzip version
// Otherwise → serves dist/app.js
How SvelteKit uses sirv
// SvelteKit's Node adapter uses sirv for static assets:
// adapter-node/src/handler.js (simplified):
import sirv from "sirv"
const assets = sirv("client", {
maxAge: 31536000,
immutable: true,
gzip: true,
brotli: true,
})
// Static assets served by sirv, dynamic routes by SvelteKit
serve-static
serve-static — Express static middleware:
Basic usage
import express from "express"
import serveStatic from "serve-static"
const app = express()
// Serve files from ./public:
app.use(serveStatic("public"))
// With options:
app.use(serveStatic("public", {
maxAge: "1d", // Cache for 1 day
etag: true, // Generate ETags
lastModified: true, // Send Last-Modified header
index: ["index.html"], // Default file
}))
app.listen(3000)
Multiple static directories
import express from "express"
import serveStatic from "serve-static"
const app = express()
// Serve from multiple directories (checked in order):
app.use(serveStatic("public")) // Check ./public first
app.use(serveStatic("assets")) // Then ./assets
app.use("/vendor", serveStatic("node_modules")) // Mount at /vendor
// /logo.png → ./public/logo.png || ./assets/logo.png
// /vendor/vue/dist/vue.js → ./node_modules/vue/dist/vue.js
Cache control
import express from "express"
import serveStatic from "serve-static"
const app = express()
// Different caching for different file types:
app.use(serveStatic("public", {
maxAge: "1y", // 1 year for static assets
immutable: true, // Immutable (for hashed filenames)
setHeaders: (res, path) => {
// No cache for HTML:
if (path.endsWith(".html")) {
res.setHeader("Cache-Control", "no-cache")
}
// Long cache for hashed assets:
if (path.match(/\.[a-f0-9]{8}\./)) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable")
}
},
}))
Range requests and ETags
import express from "express"
import serveStatic from "serve-static"
const app = express()
app.use(serveStatic("public", {
acceptRanges: true, // Support range requests (video seeking)
cacheControl: true, // Send Cache-Control header
etag: true, // Generate ETags (conditional requests)
lastModified: true, // Send Last-Modified header
dotfiles: "ignore", // Ignore dotfiles (.env, .git)
fallthrough: true, // Pass to next middleware if not found
}))
// Range requests enable:
// - Video/audio seeking
// - Resumable downloads
// - Partial content delivery
serve-handler
serve-handler — Vercel's static logic:
Basic usage
import handler from "serve-handler"
import { createServer } from "node:http"
const server = createServer((req, res) => {
return handler(req, res, {
public: "dist",
})
})
server.listen(3000)
cleanUrls and trailing slashes
import handler from "serve-handler"
// Clean URLs — /about.html → /about:
createServer((req, res) => {
return handler(req, res, {
public: "dist",
cleanUrls: true, // /about.html → accessible at /about
trailingSlash: false, // Remove trailing slashes
})
})
// With cleanUrls:
// /about → serves dist/about.html
// /blog/post → serves dist/blog/post.html
// /about.html → redirects to /about
Rewrites and redirects
import handler from "serve-handler"
createServer((req, res) => {
return handler(req, res, {
public: "dist",
rewrites: [
// SPA fallback:
{ source: "**", destination: "/index.html" },
// API proxy:
{ source: "/api/:path*", destination: "/api-handler.html" },
],
redirects: [
// 301 redirects:
{ source: "/old-page", destination: "/new-page", type: 301 },
{ source: "/blog/:slug", destination: "/posts/:slug", type: 302 },
],
})
})
Custom headers
import handler from "serve-handler"
createServer((req, res) => {
return handler(req, res, {
public: "dist",
headers: [
{
source: "**/*.@(js|css)",
headers: [{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
}],
},
{
source: "**/*.html",
headers: [{
key: "Cache-Control",
value: "no-cache",
}],
},
{
source: "**",
headers: [{
key: "X-Frame-Options",
value: "DENY",
}],
},
],
})
})
How serve CLI uses serve-handler
# The `serve` CLI by Vercel uses serve-handler:
npx serve dist
# With config (serve.json):
# {
# "cleanUrls": true,
# "trailingSlash": false,
# "rewrites": [{ "source": "**", "destination": "/index.html" }],
# "headers": [{ "source": "**", "headers": [{ "key": "X-Custom", "value": "true" }] }]
# }
Feature Comparison
| Feature | sirv | serve-static | serve-handler |
|---|---|---|---|
| Framework | Any | Express | Any |
| Brotli pre-compression | ✅ | ❌ | ❌ |
| Gzip pre-compression | ✅ | ❌ | ❌ |
| SPA fallback | ✅ (single) | ❌ | ✅ (rewrites) |
| Clean URLs | ❌ | ❌ | ✅ |
| Rewrites | ❌ | ❌ | ✅ |
| Redirects | ❌ | ❌ | ✅ |
| Custom headers | ❌ | ✅ (setHeaders) | ✅ (config) |
| Range requests | ❌ | ✅ | ❌ |
| ETags | ✅ | ✅ | ✅ |
| Used by | SvelteKit, Vite | Express | Vercel serve CLI |
| Weekly downloads | ~5M | ~10M | ~3M |
When to Use Each
Use sirv if:
- Need the fastest static file serving
- Want pre-compressed Brotli/gzip support
- Building with SvelteKit or Vite
- Need simple SPA fallback mode
Use serve-static if:
- Building an Express application
- Need range request support (video/audio streaming)
- Want mature, battle-tested middleware
- Need multiple static directories
Use serve-handler if:
- Need clean URLs (/about instead of /about.html)
- Want configurable rewrites and redirects
- Building a Vercel-like static hosting preview
- Need JSON-based configuration (serve.json)
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on sirv v2.x, serve-static v1.x, and serve-handler v6.x.