cheat sheet

serve

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

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

bash
# 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

bash
npx serve dist

Output:

text
 ┌──────────────────────────────────────────────────┐
 │   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.):

bash
npx serve -s dist

Output:

text
   ┌──────────────────────────────────────┐
   │  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

bash
# 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:

text
   ┌──────────────────────────────────────┐
   │  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:

javascript
// 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

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

Output:

text
   ┌──────────────────────────────────────┐
   │  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

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

HTTPS for local preview

bash
# 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:

text
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

json
// 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:

json
{
  "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

javascript
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