Sick of writing your own fetch wrapper on every single project? up-fetch might be what you are looking for.
up-fetch is a tiny 1kb configuration tool for the fetch API with sensible default.
- 🚀 Lightweight - 1kB gzipped, no dependency
- 🤩 Simple - same syntax as the fetch API with additional options and defaults
- 🎯 Intuitive - define the
params
andbody
as plain objects, theResponse
is parsed out of the box - 🔥 Adaptive - bring your own
serialization
andparsing
strategies for more complex cases - 💫 Reusable - create instances with custom defaults
- 💪 Strongly typed - best in class type inferrence and autocomplete
- 🤯 Validation adapters - (opt-in) validate the data for maximum type safety with zod or valibot
- 👻 Throws by default - when
response.ok
isfalse
- 😉 Works everywhere - All Modern browsers, bun, node 18+, deno (with the
npm:
specifier) - 📦 Tree Shakable - You only get what you use
npm i up-fetch
Create a new upfetch instance
import { up } from 'up-fetch'
const upfetch = up(fetch)
Make a fetch request
const todo = await upfetch('https://a.b.c', {
method: 'POST',
body: { hello: 'world' },
})
You can set some defaults for all requests.
The defaults are dynamic, they are evaluated before each request, great for handling authentication.
const upfetch = up(fetch, () => ({
baseUrl: 'https://a.b.c',
headers: { Authorization: localStorage.getItem('bearer-token') },
}))
Since the upfetch options extend the fetch api options, anything that can be done with fetch can also be done with upfetch.
// the baseUrl and Authorization header can be omitted
const todo = await upfetch('/todos', {
method: 'POST',
body: { title: 'Hello World' },
params: { some: 'query params' },
headers: { 'X-Header': 'Another header' },
signal: AbortSignal.timeout(5000),
cache: 'no-store',
})
Any fetch API implementation can be used, like undici or node-fetch
import { fetch } from 'undici'
const upfetch = up(fetch)
up-fetch default behaviour can be entirely customized
const upfetch = up(fetch, () => ({
baseUrl: 'https://a.b.c',
headers: { 'X-Header': 'hello world' },
}))
See the full options list for more details.
// before
fetch(`https://a.b.c/?search=${search}&skip=${skip}&take=${take}`)
// after
upfetch('https://a.b.c', {
params: { search, skip, take },
})
Set the baseUrl when you create the instance
export const upfetch = up(fetch, () => ({
baseUrl: 'https://a.b.c',
}))
You can then omit it on all requests
const todos = await upfetch('/todos')
The parsing method is customizable via the parseResponse option
// before
const response = await fetch('https://a.b.c')
const todos = await response.json()
// after
const todos = await upfetch('https://a.b.c')
Throws a ResponseError
when response.ok
is false
A parsed error body is available with error.data
.
The raw Response can be accessed with error.response
.
The options used make the api call are available with error.options
.
import { isResponseError } from 'up-fetch'
import { upfetch } from '...'
try {
await upfetch('https://a.b.c')
} catch (error) {
if (isResponseError(error)) {
console.log(error.data)
console.log(error.response.status)
} else {
console.log('Request error')
}
}
The 'Content-Type': 'application/json'
header is automatically set when the body is a Jsonifiable object or array. Plain objects, arrays and classes with a toJSON
method are Jsonifiable.
// before
fetch('https://a.b.c', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ post: 'Hello World' }),
})
// after
upfetch('https://a.b.c', {
method: 'POST',
body: { post: 'Hello World' },
})
up-fetch has built-in adapters for zod and valibot
First install either zod
or valibot
...
npm i zod
# or
npm i valibot
...then validate the data with the built-in tree shakeable helpers.
zod example:
import { z } from 'zod'
import { withZod } from 'up-fetch'
// ...create or import your upfetch instance
const todo = await upfetch('/todo/1', {
parseResponse: withZod(
z.object({
id: z.number(),
title: z.string(),
description: z.string(),
createdOn: z.string(),
}),
),
})
// the type of todo is { id: number, title: string, description: string, createdOn: string}
valibot example:
import { object, string, number } from 'zod'
import { withValibot } from 'up-fetch'
// ...create or import your upfetch instance
const todo = await upfetch('/todo/1', {
parseResponse: withValibot(
object({
id: number(),
title: string(),
description: string(),
createdOn: string(),
}),
),
})
// the type of todo is { id: number, title: string, description: string, createdOn: string}
In case of error the adapters will throw. You can listen to these errors with the onParsingError option.
The adapters can also be used on parseResponseError
You can setup the interceptors for all requests
const upfetch = up(fetch, () => ({
onBeforeFetch: (options) => console.log('Before fetch'),
onSuccess: (data, options) => console.log(data),
onResponseError: (error, options) => console.log(error),
onRequestError: (error, options) => console.log(error),
onParsingError: (error, options) => console.log(error),
}))
Or for single requests
upfetch('/todos', {
onBeforeFetch: (options) => console.log('Before fetch'),
onSuccess: (todos, options) => console.log(todos),
onResponseError: (error, options) => console.log(error),
onRequestError: (error, options) => console.log(error),
onParsingError: (error, options) => console.log(error),
})
Learn more here.
Worth mentionning that up-fetch does not provide any timeout
option since the AbortSignal.timeout static method is now supported everywhere.
upfetch('/todos', {
signal: AbortSignal.timeout(5000),
})
💡 Authentication
Since the defaults are evaluated at request time, the Authentication header can be defined in up
import { up } from 'up-fetch'
const upfetch = up(fetch, () => ({
headers: { Authentication: localStorage.getItem('bearer-token') },
}))
localStorage.setItem('bearer-token', 'Bearer abcdef123456')
upfetch('/profile') // Authenticated request
localStorage.removeItem('bearer-token')
upfetch('/profile') // Non authenticated request
// ❌ Don't read the storage / cookies outside of `up`
// This value will never change
const bearerToken = localStorage.getItem('bearer-token')
const upfetch = up(fetch, () => ({
headers: { Authentication: bearerToken },
}))
// ✅ Keep it inside the function call
// Checks the localStorage on each request
const upfetch = up(fetch, () => ({
headers: { Authentication: localStorage.getItem('bearer-token') },
}))
The same approach can be used with cookies
💡 Error handling
u
F438
p-fetch throws a ResponseError when response.ok
is false
.
The parsed response body is available with error.data
.
The response status is available with error.response.status
.
The options used the make the request are available with error.options
.
The type guard isResponseError
can be used to check if the error is a ResponseError
import { upfetch } from '...'
import { isResponseError } from 'up-fetch'
// with try/catch
try {
return await upfetch('https://a.b.c')
} catch (error) {
if (isResponseError(error)) {
console.log(error.name)
console.log(error.message)
console.log(error.data)
console.log(error.response.status)
console.log(error.options)
} else {
console.log(error.name)
console.log(error.message)
}
}
// with Promise.catch
upfetch('https://a.b.c').catch((error) => {
if (isResponseError(error)) {
console.log(error.name)
console.log(error.message)
console.log(error.data)
console.log(error.response.status)
console.log(error.options)
} else {
console.log(error.name)
console.log(error.message)
}
})
up-fetch also exports some listeners, useful for logging
import { up } from 'up-fetch'
import { log } from './my-logging-service'
const upfetch = up(fetch, () => ({
onResponseError(error) {
log.responseError(error)
},
onRequestError(error) {
log.requestError(error)
},
}))
upfetch('/fail-to-fetch')
💡 Delete a default option
Simply pass undefined
import { up } from 'up-fetch'
const upfetch = up(fetch, () => ({
cache: 'no-store',
params: { expand: true, count: 1 },
headers: { Authorization: localStorage.getItem('bearer-token') },
}))
upfetch('https://a.b.c', {
cache: undefined, // remove cache
params: { expand: undefined }, // only remove `expand` from the params
headers: undefined, // remove all headers
})
💡 Override a default option conditionally
You may sometimes need to conditionally override the default options provided in up
. Javascript makes it a bit tricky:
import { up } from 'up-fetch'
const upfetch = up(fetch, () => ({
headers: { 'X-Header': 'value' }
}))
❌ Don't
// if `condition` is false, the header will be deleted
upfetch('https://a.b.c', {
headers: { 'X-Header': condition ? 'newValue' : undefined }
})
In order to solve this problem, upfetch exposes the upOptions
when the options (2nd arg) are defined as a function.
upOptions
are stricly typed (const generic)
✅ Do
upfetch('https://a.b.c', (upOptions) => ({
headers: { 'X-Header': condition ? 'newValue' : upOptions.headers['X-Header'] }
}))
💡 Next.js App Router
Since up-fetch extends the fetch API, Next.js specific fetch options also work with up-fetch.
Choose a default caching strategy
import { up } from 'up-fetch'
const upfetch = up(fetch, () => ({
next: { revalidate: false },
}))
Override it for a specific request
upfetch('/posts', {
next: { revalidate: 60 },
})
See the type definitions file for more details
All options can be set either on up or on an upfetch instance except for the body
// set defaults for the instance
const upfetch = up(fetch, () => ({
baseUrl: 'https://a.b.c',
cache: 'no-store',
headers: { Authorization: `Bearer ${token}` },
}))
// override the defaults for a specific call
upfetch('/todos', {
baseUrl: 'https://x.y.z',
cache: 'force-cache',
})
upfetch adds the following options to the fetch API.
Type: string
Sets the base url for the requests
Example:
const upfetch = up(fetch, () => ({
baseUrl: 'https://a.b.c',
}))
// make a GET request to 'https://a.b.c/id'
upfetch('/id')
// change the baseUrl for a single request
upfetch('/id', { baseUrl: 'https://x.y.z' })
Type: { [key: string]: any }
The url search params.
The params defined in up
and the params defined in upfetch
are shallowly merged.
Only non-nested objects are supported by default. See the serializeParams option for nested objects.
Example:
const upfetch = up(fetch, () => ({
params: { expand: true },
}))
// `expand` can be omitted
// ?expand=true&page=2&limit=10
upfetch('https://a.b.c', {
params: { page: 2, limit: 10 },
})
// override the `expand` param
// ?expand=false&page=2&limit=10
upfetch('https://a.b.c', {
params: { page: 2, limit: 10, expand: false },
})
// delete `expand` param
// ?expand=false&page=2&limit=10
upfetch('https://a.b.c', {
params: { expand: undefined },
})
// conditionally override the expand param `expand` param
// ?expand=false&page=2&limit=10
upfetch('https://a.b.c', (upOptions) => ({
params: { expand: isTruthy ? true : upOptions.params.expand },
}))
Type: HeadersInit | Record<string, string | number | null | undefined>
Same as the fetch API headers with widened types.
The headers defined in up
and the headers defined in upfetch
are shallowly merged. \
Example:
const upfetch = up(fetch, () => ({
headers: { Authorization: 'Bearer ...' },
}))
// the request will have both the `Authorization` and the `Test-Header` headers
upfetch('https://a.b.c', {
headers: { 'Test-Header': 'test value' },
})
// override the `Authorization` header
upfetch('https://a.b.c', {
headers: { Authorization: 'Bearer ...2' },
})
// delete the `Authorization` header
upfetch('https://a.b.c', {
headers: { Authorization: null }, // undefined also works
})
// conditionally override the `Authorization` header
upfetch('https://a.b.c', (upOptions) => ({
headers: {
Authorization: isTruthy ? 'Bearer ...3' : upOptions.headers.val,
},
}))
Type: BodyInit | JsonifiableObject | JsonifiableArray | null
Note that this option is not available on up
The body of the request.
Can be pretty much anything.
See the serializeBody for more details.
Example:
upfetch('/todos', {
method: 'POST',
body: { hello: 'world' },
})
Type: (params: { [key: string]: any } ) => string
Customize the params serialization into a query string.
The default implementation only supports non-nested objects.
Example:
import qs from 'qs'
// add support for nested objects using the 'qs' library
const upfetch = up(fetch, () => ({
serializeParams: (params) => qs.stringify(params),
}))
// ?a[b]=c
upfetch('https://a.b.c', {
params: { a: { b: 'c' } },
})
Type: (body: JsonifiableObject | JsonifiableArray) => string
Default: JSON.stringify
Customize the body serialization into a string.
The body is passed to serializeBody
when it is a plain object, an array or a class instance with a toJSON
method. The other body types remain untouched
Example:
import stringify from 'json-stringify-safe'
// Add support for circular references.
const upfetch = up(fetch, () => ({
serializeBody: (body) => stringify(body),
}))
upfetch('https://a.b.c', {
body: { now: 'imagine a circular ref' },
})
Type: ParseResponse<TData> = (response: Response, options: ComputedOptions) => Promise<TData>
Customize the fetch response parsing.
By default json
and text
responses are parsed
This option is best used with a validation adapter
Example:
// create a fetcher for blobs
const fetchBlob = up(fetch, () => ({
parseResponse: (res) => res.blob(),
}))
// disable the default parsing
const upfetch = up(fetch, () => ({
parseResponse: (res) => res,
}))
With a validation adapter:
import { z } from 'zod'
import { withZod } from 'up-fetch'
// ...create or import your upfetch instance
const todo = await upfetch('/todo/1', {
parseResponse: withZod(
z.object({
id: z.number(),
title: z.string(),
description: z.string(),
createdOn: z.string(),
}),
),
})
Type: ParseResponseError<TError> = (response: Response, options: ComputedOptions) => Promise<TError>
Customize the parsing of a fetch response error (when response.ok is false)
By default a ResponseError is thrown
Example:
// throw a `CustomResponseError` when `response.ok` is `false`
const upfetch = up(fetch, () => ({
parseResponseError: (res) => new CustomResponseError(res),
}))
parseResponse
can also be used with a validation adapter
Type: <TData>(data: TData, options: ComputedOptions) => void
Called when response.ok
is true
Example:
const upfetch = up(fetch, () => ({
onSuccess: (data, options) => console.log('2nd'),
}))
upfetch('https://a.b.c', {
onSuccess: (data, options) => console.log('1st'),
})
Type: <TResponseError>(error: TResponseError, options: ComputedOptions) => void
Called when a response error was thrown (response.ok
is false
).
Example:
const upfetch = up(fetch, () => ({
onResponseError: (error, options) => console.log('Response error', error),
}))
upfetch('https://a.b.c', {
onResponseError: (error, options) => console.log('Response error', error),
})
Type: (error: Error, options: ComputedOptions) => void
Called when the fetch request fails (no response from the server).
Example:
const upfetch = up(fetch, () => ({
onRequestError: (error, options) => console.log('Request error', error),
}))
upfetch('https://a.b.c', {
onRequestError: (error, options) => console.
7434
span>log('Request error', error),
})
Type: (error: any, options: ComputedOptions) => void
Called when either parseResponse
or parseResponseError
throw.
Usefull when using a validation adapter
Example:
import { z } from 'zod'
import { withZod } from 'up-fetch'
const upfetch = up(fetch, () => ({
onParsingError: (error, options) => console.log('Validation error', error),
}))
upfetch('https://a.b.c', {
onParsingError: (error, options) => console.log('Validation error', error),
parseResponse: withZod(
z.object({
id: z.number(),
title: z.string(),
description: z.string(),
createdOn: z.string(),
}),
),
})
Type: (options: ComputedOptions) => void
Called before the request is sent.
Example:
const upfetch = up(fetch, () => ({
onBeforeFetch: (options) => console.log('2nd'),
}))
upfetch('https://a.b.c', {
onBeforeFetch: (options) => console.log('1st'),
})
- ✅ All modern browsers
- ✅ Bun
- ✅ Node 18+
- ✅ Deno (with the
npm:
specifier)