Skip to main content

sirv vs serve-static vs serve-handler: Static File Serving in Node.js (2026)

·PkgPulse Team

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 — 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

Featuresirvserve-staticserve-handler
FrameworkAnyExpressAny
Brotli pre-compression
Gzip pre-compression
SPA fallback✅ (single)✅ (rewrites)
Clean URLs
Rewrites
Redirects
Custom headers✅ (setHeaders)✅ (config)
Range requests
ETags
Used bySvelteKit, ViteExpressVercel 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.

Compare static serving and HTTP tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.