Library / SDK
vercel/serve-handler avatar
vercel/serve-handler

serve-handler is the file serving brain of Vercel's serve, and it fits inside four published files

The foundation of `serve`

617 stars119 forksJavaScriptMIT

At a glance

What is it?
vercel/serve-handler is a small HTTP handler that routes requests and serves files with rewrites, redirects, clean URLs, custom headers, directory listing control and ETag support, and it plugs into any Node HTTP server. The published package is four source files with eight runtime dependencies, MIT licensed, and its newest tag is 6.1.7 from March 2026 while the package metadata reads 6.1.8.
Who is it for?
Use serve-handler if you are serving a static site or a single page application from your own Node server and want the routing behaviour of Vercel's serve without adopting the whole tool. Do not reach for it if you need a framework, a middleware stack or a server of your own, since it is only a request handler that you mount inside something else.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 172 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A handler, not a server

serve-handler represents the core of the serve tool, and the description in the README is precise about what that means: it can be plugged into any HTTP server and is responsible for routing requests and handling responses.

That is the entire scope, and it is a smaller scope than it sounds. There is no server in this package, no listener, no configuration file format, no plugin system. It is a function that takes a request and a response and decides what to do with them.

The value of that design is that it lets you keep the server. If you already run Express, or micro, or a bare Node http server, you add one function call to your request handler and you get a static file server with the routing semantics of Vercel's serve. The alternative, which is what most people end up doing, is to write the same routing rules by hand, which is where the bugs live.

The customisation surface is described in one sentence: to customise the default behaviour, you can pass custom routing rules, provide your own methods for interacting with the file system and much more. So the package is not rigid either. The options object covers the common cases, and the hooks cover the rest.

The primary language is JavaScript, the licence is MIT, and the package is published on npm as serve-handler with the homepage pointing at the npm listing rather than at a documentation site, which tells you that the README is the documentation.

Install it and mount it in four lines

The installation is a single dependency, and the documentation gives you both package managers.

With yarn:

sh
yarn add serve-handler

Or with npm:

sh
npm install serve-handler

Wiring it into a server is shown with micro, and the example is short enough to copy:

js
const handler = require('serve-handler');

module.exports = async (request, response) => {
  await handler(request, response);
};

The shape of that snippet is the whole integration contract. The handler is asynchronous and you await it, and it writes to the response object itself, so there is no return value to inspect.

The options go in the third argument, which is how you customise the defaults:

js
await handler(request, response, {
  cleanUrls: true
});

The package metadata confirms the entry point is src/index.js, and the published files are src/index.js, src/glob-slash.js, src/directory.js and src/error.js. That last detail is worth sitting with for a moment. Four files are shipped, and one of them is an error module, which tells you that the error response is part of the design rather than something the caller assembles. A package that publishes four files has a small surface to audit, which for something sitting in your request path is a genuine advantage over a larger framework.

The runtime dependencies are correspondingly small: bytes, content-disposition, mime-types, minimatch, path-is-inside, path-to-regexp and range-parser. Three of them exist purely for matching paths, which is a hint about where the complexity in this package actually lives.

Eleven options, and most of them are about paths

The option list in the README is the API surface, and reading it tells you what problems this package is built to solve.

The public option sets the directory to serve. By default the current working directory is served, and the option accepts an absolute path or a directory relative to the working directory, with the example given being a Jekyll site served from its _site directory. The path cannot contain globs or regular expressions.

The cleanUrls option controls whether .html files can be reached without their extension. The rewrites option maps a source path to a different destination. The redirects option forwards a path to another path or to an external URL. The headers option sets custom headers for specific paths, and can overwrite the defaults. The directoryListing option disables directory listing or restricts it to certain paths, and the unlisted option excludes particular paths from that listing. The trailingSlash option removes or adds trailing slashes. The renderSingle option renders a file when a directory contains only one file. The symlinks option resolves symlinks instead of returning a 404 error. And the etag option calculates a strong ETag response header instead of relying on Last-Modified.

Two things are worth noticing in that list. There is no proxy option and no middleware option, so this is not a reverse proxy or a framework. And most of the options are about how a path is interpreted, which is consistent with the package being extracted from a tool whose hardest job is making a static directory behave like a web application.

The etag option deserves a specific mention because it changes caching semantics rather than routing. Last-Modified is a second resolution timestamp, so a file modified twice within the same second is indistinguishable from a file not modified at all. A strong ETag is computed from the content, so the answer changes whenever the bytes change, at the cost of hashing the file on every request.

cleanUrls turns a typed extension into a 301, not a 404

The cleanUrls behaviour is a small design decision that tells you more about the package than the option list does.

By default, all .html files can be accessed without their extension. And if one of these extensions is used at the end of a filename, it automatically performs a redirect with status code 301 to the same path, but with the extension dropped.

So a visitor who types index.html gets moved to /, permanently, and the search engines that follow it are told the canonical URL. The alternative, returning a 404 for the typed path, is what you get from a naive static server, and it splits your page across two URLs with the wrong one as the entry point.

The feature can be turned off entirely:

json
{
  "cleanUrls": false
}

And it can be turned on for part of the site only, by passing an array of path patterns:

json
{
  "cleanUrls": [
    "/app/**",
    "/!components/**"
  ]
}

The patterns can only contain globs that are matched using minimatch, which is the same library the package depends on. That constraint shows up repeatedly in the documentation, and it is the first thing to check when a pattern does not behave as you expected, because the alternative syntax that some of the other options accept is not available here.

The second pattern in the example, /!components/**, is worth pausing on. A path component beginning with an exclamation mark is the conventional way of marking a path as excluded, and this option set inherits that convention. The cleanUrls array uses it to keep a directory out of the extension stripping, which is what you want for a directory of raw HTML files that are not pages.

Rewrites accept globs, regular expressions and routing segments, and they are not interchangeable

The rewrites option is the one that makes this package useful for a single page application, and it is the one with the most moving parts.

The basic case serves one path while the visitor sees another:

json
{
  "rewrites": [
    { "source": "app/**", "destination": "/index.html" },
    { "source": "projects/*/edit", "destination": "/edit-project.html" }
  ]
}

Then there are routing segments, which let a value be captured from the source and substituted into the destination:

json
{
  "rewrites": [
    { "source": "/projects/:id/edit", "destination": "/edit-project-:id.html" },
  ]
}

A request for /projects/123/edit then responds with the file /edit-project-123.html.

The note at the end of the section is the part to internalise: the paths can contain globs, matched using minimatch, or regular expressions, matched using path-to-regexp. Two pattern languages in one option, with different semantics, is a real source of surprises. The glob form and the segment form are not the same thing either, and a source that happens to contain a slash behaves differently under each.

The redirects option works the same way, with one addition. It forwards a path to a different path or even an external URL, and by default all redirects use status code 301, but the type property overrides that:

json
{
  "redirects": [
    { "source": "/old-docs/:id", "destination": "/new-docs/:id" },
    { "source": "/old", "destination": "/new", "type": 302 }
  ]
}

So /old-docs/12 is forwarded with a 301 and /old with a 302. The default of 301 is the right default for permanent moves and the wrong one for a temporary switch, so the type property is the escape hatch, and a 302 that you did not ask for is a common cause of a redirect that never sticks in a browser cache.

Custom headers and the two matching languages again

The headers option sets custom headers for specific paths, and it can overwrite the default ones, which is the difference between adding a header and replacing one.

The shape is an array of rules, each with a source pattern and a list of header objects:

json
{
  "headers": [
    {
      "source" : "**/*.@(jpg|jpeg|gif|png)",
      "headers" : [{
        "key" : "Cache-Control",
        "value" : "max-age=7200"
      }]
    }, {
      "source" : "404.html",
      "headers" : [{
        "key" : "Cache-Control",
        "value" : "max-age=300"
      }]
    }
  ]

The first rule caches images for two hours and the second caches the 404 page for five minutes, which is a sensible split: a missing page is often the result of a deploy that has not finished, and caching it for a long time turns a transient condition into a persistent one.

Note the pattern syntax in the first source. It is an extglob, the @(...) form, which is minimatch syntax rather than a regular expression, and it arrives through the source field that other options match with path-to-regexp. So the same option array can contain patterns in two languages, and the documentation is explicit that paths can contain globs matched with minimatch or regular expressions matched with path-to-regexp.

That is a genuine sharp edge. It is workable once you know which rule applies to which option, and the package's own dependencies make the intent clear, but it is the thing to test when a header rule silently does not apply to the path you expected.

The remaining options in the list, directoryListing, unlisted, trailingSlash, renderSingle and symlinks, are all variations on the same theme: they change how a path maps to a file on disk, which is where the complexity of a static file server actually lives.

A four file package with an old toolchain behind it

The dependency versions in the package metadata tell you two things about the project, and they are worth checking before you adopt it.

The first is that the build tooling is from a previous era. The eslint configuration is 6.1.0, the shared configuration is @zeit/eslint-config-node, and the lint script is invoked through zeit-eslint. The integration test script is jest with the forceExit flag. The package manager is pinned to yarn 1.22.19, and the repository has a yarn.lock and a .yarnrc rather than a lockfile from a newer tool.

The second is that the runtime dependencies are conservative rather than stale. mime-types is on 2.1.18, minimatch on 3.1.5, path-to-regexp on 3.3.0, with bytes, content-disposition, path-is-inside and range-parser also pinned exactly. The range-parser is there for Range header support, which is what makes partial content requests work.

The version numbers tell their own story. The package metadata reads version 6.1.8, while the published tags are 6.1.7 from 2026-03-03, 6.1.6 from 2024-10-15 and 6.1.5 from 2022-11-01. The gap between 6.1.6 and 6.1.7 is a year and a half, and the package version is one ahead of the newest tag, so the tree is being worked on without a release being cut for it. The repository is not archived, and the last push was on 2026-05-04.

For an adopter, that is the real question: whether you want the routing behaviour of serve, and are prepared to depend on a package whose release tags are irregular, whose test tooling is from the previous decade of JavaScript, and whose error handling lives in a file the package deliberately ships as part of its public surface.

Editorial conclusion

Use serve-handler if you are serving a static site or a single page application from your own Node server and want the routing behaviour of Vercel's serve without adopting the whole tool. Do not reach for it if you need a framework, a middleware stack or a server of your own, since it is only a request handler that you mount inside something else. Before depending on it, check the version you install against the 6.1.7 tag, note that the package is built with yarn 1 and eslint 6, and confirm the path matching semantics you rely on, since glob patterns go through minimatch while regular expressions go through path-to-regexp and the two are not the same language.

Frequently asked questions

What is serve-handler and what does it do?

It is the core of the serve tool, a package that can be plugged into any HTTP server and is responsible for routing requests and handling responses. You can also pass custom routing rules and your own methods for interacting with the file system.

How do I install serve-handler and use it with a Node server?

Install it with yarn add serve-handler or npm install serve-handler, then require it and await the handler inside your request function, passing the request and response objects. Options are passed as the third argument to the handler call.

What happens when a visitor requests a page with the .html extension?

By default all .html files can be accessed without their extension, and requesting a path that ends in .html performs a 301 redirect to the same path with the extension dropped. The behaviour can be disabled with cleanUrls set to false, or limited to certain paths by passing an array of glob patterns.

How do rewrites and redirects differ in serve-handler?

Rewrites serve a different path while the visitor keeps the original URL, which suits single page applications, and they can use globs, regular expressions or routing segments such as :id. Redirects forward the visitor to a different path or an external URL, using status 301 by default, with a type property to override it to 302.

Which pattern languages does serve-handler use?

Paths can contain globs matched with minimatch, or regular expressions matched with path-to-regexp, depending on the option. The cleanUrls option is restricted to globs, and the path cannot contain globs or regular expressions.

How is the serve-handler package maintained and licensed?

It is MIT licensed, the repository is not archived, and the last push was on 2026-05-04. The package metadata reads version 6.1.8 while the newest published tag is 6.1.7 from 2026-03-03, so the release tags are irregular and the tree sits one version ahead.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. vercel/serve-handler on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/vercel-serve-handler.svg)](https://hysenlabs.com/projects/vercel-serve-handler)

Was this useful?