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)
Pre-Compression and Caching Strategy Differences
The biggest practical performance difference between these three libraries comes down to how they handle compression. sirv takes a pre-compression approach: rather than compressing files on the fly during each request, it expects you to pre-generate .br (Brotli) and .gz (gzip) versions alongside each asset at build time. When a request arrives with Accept-Encoding: br, sirv checks for app.js.br on disk and serves it directly — a raw file read with no CPU overhead per request. This is the same strategy Nginx uses for high-throughput static serving, and it's why sirv performs well under load. The trade-off is that your build pipeline must generate compressed variants, which tools like Vite and Rollup can do automatically via plugins.
serve-static relies on send, which handles ETag generation, Last-Modified headers, and If-None-Match/If-Modified-Since conditional request validation. It does not perform on-the-fly compression but handles range requests natively — a critical feature for serving video and audio files where browsers need to seek to arbitrary byte positions. If you're building an Express app that streams MP4 files directly from disk, serve-static is the correct choice and sirv is not a drop-in replacement. The setHeaders callback gives fine-grained control: you can set Cache-Control: no-cache for HTML files (to ensure users always get the latest content) while setting immutable for hashed JS/CSS bundles.
serve-handler compresses responses inline via the compression option and handles decompression transparently, but it shines in routing features that the other two lack entirely. The cleanUrls option — serving about.html when users request /about — is essential for static sites generated by frameworks like Astro or Eleventy that produce HTML files without extensions in their output. Without this, deploying a static site behind serve-handler would require either renaming all output files or adding redirect rules. The JSON-based configuration model (serve.json) mirrors the vercel.json format, making serve-handler an accurate local replica of Vercel's static hosting behavior for previewing deployments.
SvelteKit, Vite, and Framework Integration
sirv's ~5M weekly downloads are heavily influenced by SvelteKit's Node adapter. When you run npx sv create and select the Node adapter, sirv is wired in automatically to serve the client/ directory (compiled static assets) while SvelteKit handles SSR routes. The integration uses immutable: true for hashed assets and gzip: true/brotli: true assuming your build tool generates compressed variants. Understanding this context matters when debugging SvelteKit production deployments: if static assets aren't being served with Brotli, it's usually because the .br files were not generated, not a sirv configuration issue.
Vite's preview command uses sirv under the hood to serve dist/ after a production build. This means the behavior you see with vite preview — including SPA fallback (single: true) and compression serving — is sirv behavior, not Vite behavior. If you're deploying a Vite SPA to your own Node.js server, using sirv directly with the same options gives you vite preview semantics in production.
serve-static's relationship with Express is tightly coupled: it uses Express's res.sendFile semantics and integrates with Express's error-handling middleware chain via the fallthrough option. Setting fallthrough: false causes serve-static to pass a 404 error to Express's next middleware instead of silently moving on, which is important for logging and error tracking. Multiple app.use(serveStatic(...)) calls are checked in order, making it easy to layer a custom public/ directory on top of a node_modules/ vendor directory without additional routing logic.
Deployment Patterns and Edge Cases
For containerized deployments, sirv with pre-compressed assets is the most Docker-layer-friendly approach: the compressed files are generated during the build stage and baked into the image, so the runtime container needs no zlib CPU time at all. serve-static is well-suited for monolithic Express apps where static serving is one of many responsibilities and simplicity matters more than raw throughput.
serve-handler introduces a subtle behavior difference from the others: by default it enables directory listings (directoryListing: true), which can be a security concern if your public/ directory contains files you didn't intend to expose. In production, always set directoryListing: false explicitly. Another serve-handler edge case is symlink handling — it follows symlinks by default, which can expose parent directories if your build process creates symlinks outside the public/ root. Both sirv and serve-static are more conservative about symlink traversal by default.
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.
Compare static serving and HTTP tooling on PkgPulse →
In 2026, sirv is the default choice for fast static file serving in Node.js HTTP servers and dev tools, while serve-static integrates cleanly into Express middleware chains and serve-handler powers Vercel's own local development server.
An important operational difference in how these libraries handle index.html fallback illustrates their design philosophies. When a client requests /about and no about file exists on disk, sirv with single: true serves index.html — enabling client-side routing in SPAs. Without single: true, sirv returns a 404. serve-static always returns 404 for missing files unless fallthrough: false passes the error to Express's error handler — it has no SPA mode by design, since SPAs deployed behind Express typically use a catch-all route instead. serve-handler's rewrites configuration makes the fallback behavior explicit: { "source": "**", "destination": "/index.html" } is the SPA fallback rewrite, and it applies after static file resolution (so /app.js still serves the file, not index.html). Understanding this execution order — static file first, then rewrite — prevents the common mistake of a rewrite that accidentally serves index.html for existing asset paths.
One security nuance that catches teams moving from Express to a sirv-based Node.js server: sirv does not set a Content-Security-Policy header by default, and neither does serve-static. Both libraries focus on serving files efficiently rather than setting security headers. In an Express application, you would add helmet middleware to set CSP, HSTS, and other security headers before serveStatic. With sirv in a raw Node.js HTTP server, you need to wrap the sirv handler and add security headers manually, or place an nginx reverse proxy in front that injects the headers. serve-handler's headers configuration option makes it the easiest of the three for setting per-route or global response headers, which is part of why it's used as the backend for deployment preview servers where controlling cache and security headers is important.
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.