<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/sirv-vs-serve-static-vs-serve-handler-static-file-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/sirv-vs-serve-static-vs-serve-handler-static-file-2026/raw.md -->
<!-- Source path: content/guides/sirv-vs-serve-static-vs-serve-handler-static-file-2026.mdx -->

---
og_image: "/images/guides/sirv-vs-serve-static-vs-serve-handler-static-file-2026.webp"
title: "sirv vs serve-static vs serve-handler 2026"
description: "Compare sirv, serve-static, and serve-handler for serving static files in Node.js. File serving middleware, SPA fallback, caching headers, and which to choose."
date: "2026-03-09"
authors: ["team"]
tier: 2
tags: ["nodejs", "typescript", "developer-tools", "api"]
---

## 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 `serve` CLI, 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](https://github.com/lukeed/sirv) — lightweight static server:

### Basic usage

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
// 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](https://github.com/expressjs/serve-static) — Express static middleware:

### Basic usage

```typescript
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

```typescript
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

```typescript
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

```typescript
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](https://github.com/vercel/serve-handler) — Vercel's static logic:

### Basic usage

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```bash
# 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 →](https://www.pkgpulse.com)*

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](/guides/pm2-vs-node-cluster-vs-tsx-watch-process-2026) and [h3 vs polka vs koa 2026](/guides/h3-vs-polka-vs-koa-lightweight-http-frameworks-nodejs-2026), [better-sqlite3 vs libsql vs sql.js](/guides/better-sqlite3-vs-libsql-vs-sql-js-sqlite-nodejs-2026).*
