skip to content

serve — Static file server for development and one-off hosting

Package-level reference for serve on npm — install, SPA fallback, auth, CORS, and when to reach for caddy or python -m http.server instead.

9 min read 17 snippets deep dive

serve#

What it is#

serve is a zero-config static-file HTTP server from Vercel, designed for the “I just built a SPA and want to verify it locally” workflow. Point it at a directory; it serves files with content-type detection, gzip compression, directory listings, and (crucially) SPA-mode rewrites so /profile resolves to index.html for client-side routing.

It’s not a production server — there’s no clustering, no caching tier, no graceful reload. But for vercel dev-style local previews, demo deployments, and CI smoke tests of build output, it’s the path of least friction.

Install#

# As a global dev tool (preferred — version not pinned per project)
npm install -g serve

# As a project dev dep (when you want `npx serve` reproducibly)
npm install -D serve

# One-off
npx serve dist

Output: serve binary on PATH. Library import serve (programmatic) and serve-handler (Express/Connect-compatible middleware) also exported.

Versioning & Node support#

  • Current major line is 14.x (released 2023) — major rewrite for modular handlers, dropped Node 14 support.
  • Recent releases require Node 18+ or 20+.
  • The bundled serve-handler package (the actual serving logic) is published separately and used by Vercel internals as well.

Package metadata#

  • Maintainer: Vercel.
  • Project home: github.com/vercel/serve
  • npm: npmjs.com/package/serve
  • License: MIT
  • First released: 2014 (as list, renamed to serve in 2017).
  • Downloads: ~5-7 million per week — partly direct, partly transitive via various CLI scaffolds.

Peer dependencies & extras#

serve bundles its own dependencies (chalk, clipboardy, compression, content-disposition, mime, etc.). No peer deps and no companion packages typically needed.

Adjacent toolPurpose
serve-handlerThe middleware version — drop into Express/Connect/Polka if you want serve’s logic but custom routing
vercel devVercel’s full dev server; if you’re targeting Vercel deployment, prefer this over plain serve
local-web-serverAlternative with more features (HTTPS, proxy, mock endpoints)
http-serverThe other popular static server — see comparison below

Alternatives#

ToolTrade-off
http-serverThe older, more bare-bones competitor. Defaults to no SPA fallback; better for pure static (no rewrites). Smaller bundle.
python -m http.server PORTBuilt into Python 3. Zero deps. No SPA fallback, no compression, no auto-MIME beyond a small table.
bun --bun serve (if Bun installed)Bun’s built-in static server. Faster than serve; require Bun.
CaddyProduction-grade static server with auto-HTTPS, HTTP/3, file-server module. Overkill for local but excellent for any “deployed static” use.
nginxThe classic. Heavier setup. Use when you outgrow serve in production.
vite previewBuilt into Vite — serves your build output the same way Vite would in dev. Smaller install footprint if you already have Vite.

Common gotchas#

  1. SPA mode is opt-in. serve defaults to a 404 for missing paths. SPAs need serve -s (single-page mode) which rewrites all unmatched requests to index.html.
  2. The dist/ arg is optional. serve with no arg serves the current directory — useful but dangerous (will expose .env, node_modules, etc., if you forget).
  3. Directory listings default to ON. A path with no index.html shows a navigable directory tree. Disable with -l (no listing) or a serve.json config.
  4. Default port is 3000. Override with -p PORT or env PORT=5000. Collisions with other dev servers are common.
  5. HTTPS is NOT built-in. Use --ssl-cert / --ssl-key with your own self-signed cert, or mkcert for trusted local certs.
  6. No --watch / file-change reload. For live-reload, use Vite’s preview or pair with live-server.
  7. No proxy support. If you need to proxy /api/* to a backend during preview, switch to vite preview --host (or a tool like local-web-server).
  8. Clipboardy may break in headless CI. On first run, serve tries to copy the URL to clipboard; in containers without xclip/wl-clipboard it logs a warning. Set CLIPBOARD=false or use --no-clipboard (v14+).

Real-world recipes#

Serve a static directory#

npx serve dist

Output:

 ┌──────────────────────────────────────────────────┐
 │   Serving!                                       │
 │   - Local:    http://localhost:3000              │
 │   - Network:  http://192.168.1.5:3000            │
 │   Copied local address to clipboard!             │
 └──────────────────────────────────────────────────┘

SPA fallback#

For any SPA where the router handles client-side paths (React Router, Vue Router, etc.):

npx serve -s dist

Output:

   ┌──────────────────────────────────────┐
   │  Serving!                            │
   │  Local: http://localhost:3000        │
   │  SPA mode: 404 → index.html          │
   └──────────────────────────────────────┘

-s (single-page mode) rewrites any 404 to /index.html, so /users/42 reaches the SPA shell which then routes.

Custom port and host binding#

# Pick a non-default port and bind to all interfaces
npx serve -l 5000 dist

# Bind to a specific interface
npx serve -l tcp://127.0.0.1:5000 dist

# Unix socket
npx serve -l unix:/tmp/serve.sock dist

Output:

   ┌──────────────────────────────────────┐
   │  Serving!                            │
   │  Local: http://localhost:5000        │
   └──────────────────────────────────────┘

Basic auth via serve.json#

serve reads serve.json from the served directory. Auth is implemented in a headers or redirects block (no native basic-auth flag), but the most common usage is serve-handler programmatically:

// server.mjs
import { createServer } from "node:http";
import handler from "serve-handler";

createServer((req, res) => {
  const auth = req.headers.authorization;
  if (auth !== `Basic ${Buffer.from("admin:hunter2").toString("base64")}`) {
    res.writeHead(401, { "WWW-Authenticate": 'Basic realm="preview"' });
    return res.end();
  }
  return handler(req, res, { public: "dist" });
}).listen(3000);

For built-in basic auth without code, use local-web-server or a Caddy reverse proxy.

CORS headers via serve.json#

// serve.json (in the served directory)
{
  "headers": [
    {
      "source": "**/*",
      "headers": [
        { "key": "Access-Control-Allow-Origin", "value": "*" },
        { "key": "Cache-Control", "value": "no-store" }
      ]
    }
  ]
}
npx serve dist

Output:

   ┌──────────────────────────────────────┐
   │  Serving!                            │
   │  Local: http://localhost:3000        │
   └──────────────────────────────────────┘

The headers apply to all responses. source is a glob; use specific patterns for per-route control.

Custom rewrites and redirects#

// serve.json
{
  "rewrites": [
    { "source": "/api/:slug", "destination": "/api.json" }
  ],
  "redirects": [
    { "source": "/old/:slug", "destination": "/new/:slug", "type": 301 }
  ]
}

HTTPS for local preview#

# Generate a cert with mkcert (https://github.com/FiloSottile/mkcert)
mkcert -install
mkcert localhost

npx serve --ssl-cert localhost.pem --ssl-key localhost-key.pem dist

Output:

The local CA is now installed in the system trust store!
Created a new certificate valid for localhost
   ┌──────────────────────────────────────┐
   │  Serving!                            │
   │  Local: https://localhost:3000       │
   └──────────────────────────────────────┘

Output now binds to https://localhost:3000 with a trusted cert.

Smoke-testing a build output#

// package.json
{
  "scripts": {
    "build": "vite build",
    "preview": "serve -s dist",
    "smoke": "concurrently -k -s first \"npm:preview\" \"wait-on http://localhost:3000 && playwright test\""
  }
}

The CI runs npm run build && npm run smoke to confirm the built output works end-to-end.

Production deployment#

Don’t. serve is not a production server.

For static production hosting:

  • Vercel / Netlify / Cloudflare Pages — zero-config CDN-backed static hosting, often free.
  • Caddy — single-binary production server with auto-HTTPS.
  • nginx — the classic high-performance static server.
  • Bunbun --bun serve is production-capable.
  • AWS S3 + CloudFront, Cloudflare R2, etc. — for very-high-volume.

serve lacks: HTTP/2, HTTP/3, automatic TLS, request logging that respects log-rotation, graceful reload, process supervision, ACME integration. The architecture is “single Node process; restart on crash” — fine for previews, not for Cache-Control-aware production.

Performance tuning#

serve is a thin layer over Node’s http module. Performance knobs are minimal:

  • --cors adds a CORS header to every response — not a perf issue, but watch large file responses.
  • Compression is on by default for text mime-types. Disable with --no-compression if you’re hosting already-gzipped artefacts (e.g. brotli pre-compressed).
  • No HTTP/2. For static-only with HTTP/2, switch to Caddy or run nginx in front.

For high throughput (>1k req/s), use Caddy or nginx — Node’s single-threaded http becomes the bottleneck.

Version migration guide#

From → ToHighlights
12 → 13New CLI flags; deprecated --single (use -s).
13 → 14Major rewrite. serve-handler extracted as separate package. Node 14 dropped. serve.json schema clarified.

From legacy alternatives#

If you’re migrating from python -m http.server, the main extras are: SPA fallback, compression, configurable headers, MIME-type breadth. Direct CLI replacement; same workflow.

Security considerations#

  1. Default directory listings expose secrets. Serving the wrong directory (. from a project root) leaks .env, node_modules, .git. Always serve a specific subdirectory (dist, public).
  2. No authentication by default. Anyone with network access to the port can fetch every file. Bind to 127.0.0.1 for local-only with -l tcp://127.0.0.1:PORT.
  3. Path traversal protection is in serve-handler. As of recent versions, ../ requests are rejected. Keep up to date — older versions had path-traversal CVEs.
  4. --ssl-cert / --ssl-key use whatever you provide. Self-signed certs work; for public exposure use Let’s Encrypt via Caddy, not serve.
  5. No rate limiting, no DDoS protection. Putting serve directly on the public internet invites trouble.

Configuration patterns#

serve.json#

Place in the served directory. Full schema includes:

{
  "public": "dist",
  "cleanUrls": true,
  "trailingSlash": false,
  "rewrites": [
    { "source": "/app/**", "destination": "/index.html" }
  ],
  "redirects": [
    { "source": "/old/**", "destination": "/new/:_/*", "type": 301 }
  ],
  "headers": [
    {
      "source": "**/*.{js,css}",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
    }
  ],
  "directoryListing": false,
  "renderSingle": true,
  "etag": true,
  "symlinks": false
}

Programmatic via serve-handler#

import { createServer } from "node:http";
import handler from "serve-handler";

createServer((req, res) =>
  handler(req, res, { public: "dist", rewrites: [{ source: "**", destination: "/index.html" }] })
).listen(3000);

This is what serve itself does under the hood.

Troubleshooting common errors#

  • EADDRINUSE — port collision. serve -l 5001 dist or kill the other process (lsof -i :3000).
  • “Copied local address to clipboard” warning in CI — clipboardy can’t find a clipboard tool. Set CLIPBOARD=false env or use --no-clipboard.
  • 404s on every SPA route — missing -s flag.
  • MIME-type mismatchserve uses the mime package’s tables. For non-standard types (.wasm, .json variants), add a headers block in serve.json setting Content-Type explicitly.
  • CORS errors — add headers entry in serve.json or use --cors flag (v14+: --cors enables permissive CORS for all routes).

Ecosystem integrations#

  • Vercel deploymentsserve is what Vercel runs internally for static builds. Local parity is one reason to prefer it over http-server.
  • CI smoke tests — pair with start-server-and-test or wait-on for “build → serve → run e2e → tear down” loops.
  • Docker — minimal Dockerfile: FROM node:20-alpine; RUN npm install -g serve; COPY dist /app; CMD serve -s -l 3000 /app. Decent for ephemeral previews; not for production.

When NOT to use this#

  • Production traffic. Use Caddy, nginx, Vercel, Netlify, Cloudflare Pages, or any real CDN.
  • You need a reverse proxy / API mocking. Use Vite preview, local-web-server, or set up a real proxy with Caddy.
  • Live-reload during development. Use the framework’s dev server (Vite, Next, Astro, etc.) — they hot-reload; serve doesn’t.
  • HTTPS without configuration. Caddy auto-provisions Let’s Encrypt; serve requires manual cert files.
  • You already have Bun installed. bun --bun serve is faster and built in.
  • You want HTTP/2 or HTTP/3. Not supported.
  • You only need to share one file briefly. python -m http.server or bun --bun serve are smaller-footprint alternatives.

serve is the right tool for the 30-second “verify my SPA build worked” loop. Outside that, reach for a more specialised tool.

See also#