Library / SDK
posva/mande avatar
posva/mande

mande, an 800 byte fetch wrapper that removes the boilerplate you keep rewriting

800 bytes modern wrapper around fetch with smart defaults

1,336 stars43 forksTypeScriptMIT

At a glance

What is it?
mande is a TypeScript wrapper around fetch that sets the JSON headers, serialises the body and rejects on non-2xx responses by default, so a call that takes twenty lines of fetch becomes a method call. The library is small enough that the size is part of the design, and the size-limit config in package.json enforces it.
Who is it for?
mande suits a project that makes many small API calls and wants the JSON content negotiation, body serialisation and status check to be the default rather than something each call site reimplements, and it suits a team that treats bundle size as a budget.
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 5 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

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

From daily snapshots

Star history

  • posva/mande1,336 ★ · -1
Star history
Repository2026-10-202026-11-06
posva/mande1.3k1.3k

-1 Stars in the last 17 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

The example that makes the case

The README opens with a side-by-side comparison, and the left side is the code everyone has written.

js
// creating a new user
fetch('/api/users', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'Dio',
    password: 'irejectmyhumanityjojo',
  },
})
  .then((response) => {
    if (response.status >= 200 && response.status < 300) {
      return response.json()
    }
    // reject if the response is not 2xx
    throw new Error(response.statusText)
  })
  .then((user) => {
    // ...
  })

The right side is the same call through mande:

js
const users = mande('/api/users')

users
  .post({
    name: 'Dio',
    password: 'irejectmyhumanityjojo',
  })
  .then((user) => {
    // ...
  })

What disappeared is specific. The Accept and Content-Type headers are set by default. The body is serialised for you, so a plain object is enough and there is no JSON.stringify call. The 2xx check is done for you, and a non-2xx response rejects rather than resolving with a body you have to check.

That last one is the behavioural difference worth understanding, because it is the opposite of what fetch does. fetch resolves for any HTTP status, including a 500, and it is the caller who has to decide whether that is an error. mande moves that decision into the library, which is convenient until you want to handle a 404 as a normal outcome rather than an exception, at which point you need to know how to opt out.

The library requires fetch support, which the README states plainly at the top, and it is described as a simple, light and extensible wrapper around fetch with smart defaults. The description in the repository metadata calls it 800 bytes modern wrapper around fetch with smart defaults.

Per-instance options, global defaults, and how to remove a header

The layering of options is the part that determines how the library behaves in a real application, and there are three levels.

The first is the instance. A mande instance holds an options object, and you can mutate it after creation:

js
const todos = mande('/api/todos', todosApiOptions)

export function setToken(token) {
  // todos.options will be used for all requests
  todos.options.headers.Authorization = 'Bearer ' + token
}

export function clearToken() {
  delete todos.options.headers.Authorization
}

The second is global. There is a defaults export that applies to every mande instance:

js
import { defaults } from 'mande'

defaults.headers.Authorization = 'Bearer token'

The third is per request, and the pattern the README recommends is to create a small layer per API resource, so the URL, the default options and the exported functions live together:

js
// api/users
import { mande } from 'mande'

const users = mande('/api/users', usersApiOptions)

export function getUserById(id) {
  return users.get(id)
}

export function createUser(userData) {
  return users.post(userData)
}

The third example in the README is the one that shows how the defaults are overridden, and it introduces the mechanism worth remembering. Passing null to a header deletes it rather than setting it to the string null:

ts
const legacy = mande('/api/v1/data', {
  headers: {
    // override all requests
    'Content-Type': 'application/xml',
  },
})

// override only this request
legacy.post(new FormData(), {
  headers: {
    // overrides Accept: 'application/json' only for this request
    Accept: null,
    'Content-Type': null,
  },
})

So a null header at the request level removes the library's default for that call only, and a header set on the instance replaces it for all calls on that instance. That is a compact and legible model, and it is the reason a plain object can be passed as a body without the caller having to think about content negotiation at all.

TypeScript generics and a Nuxt integration with a caveat

The TypeScript support is minimal and effective. All methods defined on a mande instance accept a type generic to type their return:

ts
const todos = mande('/api/todos', globalOptions)

todos.get<{ text: string; id: number; isFinished: boolean }[]>().then((todos) => {
  // todos is correctly typed
})

The generic is on the method rather than on the instance, which is a deliberate choice. A resource endpoint usually returns one shape, so typing the call site rather than the instance keeps the API layer free of type parameters that would have to be threaded through every helper function.

The server side rendering section is where the library gets more complicated, and the README is careful about the limits.

To make mande work on the server you need to provide a fetch polyfill and to use full URLs rather than absolute URLs starting with a slash, because a relative path has no meaning on the server. The example shows the base URL being chosen per environment and the polyfill being selected with a ternary on process.server:

js
export const BASE_URL = process.server
  ? process.env.NODE_ENV !== 'production'
    ? 'http://localhost:3000'
    : 'https://example.com'
  : // on client, do not add the domain, so urls end up like `/api/something`
    ''

const fetchPolyfill = process.server ? require('node-fetch') : fetch
const contents = mande(BASE_URL + '/api', {}, fetchPolyfill)

The polyfill is the third argument to mande, which is how a single implementation of the wrapper runs against both the browser's fetch and a Node implementation.

For Nuxt there is a separate integration and a warning. The README notes that if you are doing SSR with authentication, Nuxt 3 has not been adapted yet, and points at issue 308. And when using Nuxt with SSR you must wrap exported functions so they automatically proxy cookies and headers on the server:

js
import { mande, nuxtWrap } from 'mande'
const fetchPolyfill = process.server ? require('node-fetch') : fetch
const users = mande(BASE_URL + '/api/users', {}, fetchPolyfill)

export const getUserById = nuxtWrap(users, (api, id: string) => api.get(id))

The reason is given at the end: this prevents requests from accidentally sharing headers or bearer tokens. On a server, a module-level instance is a shared object, so without the wrapper a token set by one request could leak into another.

The module also has to be registered in nuxt.config.js under buildModules with the mande/nuxt entry, and the TypeScript users have to add mande/nuxt to the types array in tsconfig.json.

Size as a design constraint, and the tooling that keeps it there

The 800 byte figure in the description is not a slogan, because the repository has tooling that enforces it.

There is a size-check/ directory at the top level and a size script, and the package.json carries a size-limit configuration with one entry named Mande, pointing at dist/index.mjs with the import set to { mande }. That is the entry the size is measured against, and it means the published bundle has a hard budget that a pull request which grows it will break.

The rest of the toolchain is modern and small. The build is tsdown, the linter is oxlint with a plugin specific to the author, the formatter is oxfmt, the test runner is vitest with a workspace file, the type test is a separate tsc build, and the docs are generated with typedoc. There is a separate test:types script alongside test:unit, which is a good sign, because a library whose whole value is type inference needs its types tested independently of its runtime.

The release script is a TypeScript file run with node, and the changelog is generated with conventional-changelog in the angular flavour, and the version bump uses semver. The package is an ES module with sideEffects set to false, and the published files are just dist, the LICENSE and the README.

That combination of a size budget, a strict lint setup and a type test tells you what kind of library this is. It is a dependency you are expected to be able to read in full, which is the point of a 800 byte wrapper around something that already exists in the platform.

The related projects section names fetchival, from which part of the code was borrowed and whose API is very similar, and axios. The first is a genuine credit and explains the shape of the API. The second is the comparison everyone makes, and the difference in approach is that axios carries its own adapter and interceptor machinery, while mande stays at the fetch layer and adds defaults instead.

Editorial conclusion

mande suits a project that makes many small API calls and wants the JSON content negotiation, body serialisation and status check to be the default rather than something each call site reimplements, and it suits a team that treats bundle size as a budget. It is the wrong choice if you need the full feature set of a larger HTTP client, because the library deliberately stays at the fetch layer with no interceptor pipeline, no retry policy and no progress events, and it is the wrong choice if you are on Nuxt 3 with server side rendering and authentication, where the README says the integration has not been adapted yet. Before adopting it, check the size-limit entry in package.json, because the 800 byte figure is enforced by tooling and your usage of the API surface may push it, and read the Nuxt section if you render on the server.

Frequently asked questions

What does mande do differently from plain fetch?

It sets the Accept and Content-Type headers to application/json by default, serialises a plain object body for you, and rejects on a non-2xx response instead of resolving, so a typical POST becomes a single method call with no JSON.stringify and no status check.

How do I remove a default header in mande?

Pass null for that header. At the instance level it applies to all requests on that instance, and at the request level it overrides the default for that call only, as in legacy.post(new FormData(), { headers: { Accept: null, 'Content-Type': null } }).

How do I add an Authorization header to all mande requests?

Globally with the defaults export, defaults.headers.Authorization = 'Bearer token', or per instance by mutating the instance options, todos.options.headers.Authorization, which the README says will be used for all requests on that instance.

Does mande work with Nuxt and server side rendering?

There is a Nuxt integration, but the README states that Nuxt 3 has not been adapted yet for SSR with authentication, pointing at issue 308. For Nuxt with SSR you must wrap exported functions with nuxtWrap so cookies and headers are proxied on the server and tokens do not leak between requests.

How is the 800 byte size of mande kept in check?

The repository has a size-check directory and a size script, and the package.json carries a size-limit configuration with an entry named Mande pointing at dist/index.mjs. The budget is enforced by tooling, so a change that grows the bundle will fail the check.

Official sources

  1. License: MIT
  2. posva/mande on GitHub
  3. Project website
  4. README
  5. Releases
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/posva-mande.svg)](https://hysenlabs.com/projects/posva-mande)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/posva-mande.svg)](https://hysenlabs.com/projects/posva-mande)

Was this useful?