
Security News
CVE Volume Surges Past 48,000 in 2025 as WordPress Plugin Ecosystem Drives Growth
CVE disclosures hit a record 48,185 in 2025, driven largely by vulnerabilities in third-party WordPress plugins.
better-call
Advanced tools
Better call is a tiny web framework for creating endpoints that can be invoked as a normal function or mounted to a router to be served by any web standard compatible server (like Bun, node, nextjs, sveltekit...) and also includes a typed RPC client for t
Better call is a tiny web framework for creating endpoints that can be invoked as a normal function or mounted to a router to be served by any web standard compatible server (like Bun, node, nextjs, sveltekit...) and also includes a typed RPC client for type-safe client-side invocation of these endpoints.
Built for typescript and it comes with a very high performance router based on rou3.
pnpm i better-call
Make sure to install standard schema compatible validation library like zod.
pnpm i zod
The building blocks for better-call are endpoints. You can create an endpoint by calling createEndpoint and passing it a path, options and a handler that will be invoked when the endpoint is called.
// endpoint.ts
import { createEndpoint } from "better-call"
import { z } from "zod"
export const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
return {
item: {
id: ctx.body.id
}
}
})
// Now you can call the endpoint just as a normal function.
const item = await createItem({
body: {
id: "123"
}
})
console.log(item); // { item: { id: '123' } }
OR you can mount the endpoint to a router and serve it with any web standard compatible server.
The example below uses Bun
// router.ts
import { createRouter } from "better-call"
import { createItem } from "./endpoint"
export const router = createRouter({
createItem
})
Bun.serve({
fetch: router.handler
})
Then you can use the rpc client to call the endpoints on client.
// client.ts
import type { router } from "./router" // import router type
import { createClient } from "better-call/client"
const client = createClient<typeof router>({
baseURL: "http://localhost:3000"
})
const item = await client("@post/item", {
body: {
id: "123"
}
})
console.log(item) // { data: { item: { id: '123' } }, error: null }
Endpoints are building blocks of better-call.
The path is the URL path that the endpoint will respond to. It can be a direct path or a path with parameters and wildcards.
// direct path
const endpoint = createEndpoint("/item", {
method: "GET",
}, async (ctx) => {})
// path with parameters
const endpoint = createEndpoint("/item/:id", {
method: "GET",
}, async (ctx) => {
return {
item: {
id: ctx.params.id
}
}
})
// path with wildcards
const endpoint = createEndpoint("/item/**:name", {
method: "GET",
}, async (ctx) => {
// the name will be the remaining path
ctx.params.name
})
The body option accepts a standard schema and will validate the request body. If the request body doesn't match the schema, the endpoint will throw a validation error. If it's mounted to a router, it'll return a 400 error with the error details.
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
return {
item: {
id: ctx.body.id
}
}
})
The query option accepts a standard schema and will validate the request query. If the request query doesn't match the schema, the endpoint will throw a validation error. If it's mounted to a router, it'll return a 400 error with the error details.
const createItem = createEndpoint("/item", {
method: "GET",
query: z.object({
id: z.string()
})
}, async (ctx) => {
return {
item: {
id: ctx.query.id
}
}
})
You can specify a single HTTP method or an array of methods for an endpoint.
// Single method
const getItem = createEndpoint("/item", {
method: "GET",
}, async (ctx) => {
return { item: "data" }
})
// Multiple methods
const itemEndpoint = createEndpoint("/item", {
method: ["GET", "POST"],
}, async (ctx) => {
if (ctx.method === "POST") {
// handle POST - create/update
return { created: true }
}
// handle GET - read only
return { item: "data" }
})
When calling an endpoint with multiple methods directly (not through HTTP), the method parameter is optional and defaults to the first method in the array:
// Defaults to "GET" (first in array)
const result = await itemEndpoint({ headers })
// Explicitly specify POST
const result = await itemEndpoint({ headers, method: "POST" })
By default, all media types are accepted, but only a handful of them have a built-in support:
application/json and custom json suffixes are parsed as a JSON (plain) objectapplication/x-www-form-urlencoded and multipart/form-data are parsed as a FormData objecttext/plain is parsed as a plain stringapplication/octet-stream is parsed as an ArrayBufferapplication/pdf, image/* and video/* are parsed as Blobapplication/stream is parsed as ReadableStreamSimilarly, returning a supported type from a handler will properly serialize it with the correct Content-Type as specified above.
You can restrict which media types (MIME types) are allowed for request bodies using the allowedMediaTypes option. This can be configured at both the router level and the endpoint level, with endpoint-level configuration taking precedence.
When a request is made with a disallowed media type, the endpoint will return a 415 Unsupported Media Type error.
Note: Please note that using this option won't add parsing support for new media types, it only restricts the media types that are already supported.
Router-level configuration:
const router = createRouter({
createItem,
updateItem
}, {
// All endpoints in this router will only accept JSON
allowedMediaTypes: ["application/json"]
})
Endpoint-level configuration:
const uploadFile = createEndpoint("/upload", {
method: "POST",
metadata: {
// This endpoint will only accept form data
allowedMediaTypes: ["multipart/form-data"]
}
}, async (ctx) => {
return { success: true }
})
Multiple media types:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
}),
metadata: {
// Accept both JSON and form-urlencoded
allowedMediaTypes: [
"application/json",
"application/x-www-form-urlencoded"
]
}
}, async (ctx) => {
return {
item: {
id: ctx.body.id
}
}
})
Endpoint overriding router:
const router = createRouter({
createItem,
uploadFile
}, {
// Default: only accept JSON
allowedMediaTypes: ["application/json"]
})
const uploadFile = createEndpoint("/upload", {
method: "POST",
metadata: {
// This endpoint overrides the router setting
allowedMediaTypes: ["multipart/form-data", "application/octet-stream"]
}
}, async (ctx) => {
return { success: true }
})
Note: The validation is case-insensitive and handles charset parameters automatically (e.g.,
application/json; charset=utf-8will matchapplication/json).
The requireHeaders option is used to require the request to have headers. If the request doesn't have headers, the endpoint will throw an error. This is only useful when you call the endpoint as a function.
const createItem = createEndpoint("/item", {
method: "GET",
requireHeaders: true
}, async (ctx) => {
return {
item: {
id: ctx.headers.get("id")
}
}
})
createItem({
headers: new Headers()
})
The requireRequest option is used to require the request to have a request object. If the request doesn't have a request object, the endpoint will throw an error. This is only useful when you call the endpoint as a function.
const createItem = createEndpoint("/item", {
method: "GET",
requireRequest: true
}, async (ctx) => {
return {
item: {
id: ctx.request.id
}
}
})
createItem({
request: new Request()
})
This is the function that will be invoked when the endpoint is called. The signature is:
const handler = async (ctx) => response;
Where ctx is:
request, headers, body, query, params and a few helper functions. If there is a middleware, the context will be extended with the middleware context.And response is any supported response type:
Response objectstring, number, boolean, an object or an arrayctx.json() helperBelow, we document all the ways in which you can create a response in your handler:
You can use the ctx.setStatus(status) helper to change the default status code of a successful response:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
ctx.setStatus(201);
return {
item: {
id: ctx.body.id
}
}
})
Sometimes, you want to respond with an error, in those cases you will need to throw better-call's APIError error or use the ctx.error() helper, they both have the same signatures!
If the endpoint is called as a function, the error will be thrown but if it's mounted to a router, the error will be converted to a response object with the correct status code and headers.
import { APIError } from "better-call"
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
if (ctx.body.id === "123") {
throw ctx.error("BAD_REQUEST", {
message: "Id is not allowed"
})
}
if (ctx.body.id === "456") {
throw new APIError("BAD_REQUEST", {
message: "Id is not allowed"
})
}
return {
item: {
id: ctx.body.id
}
}
})
You can also instead throw using a status code:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
if (ctx.body.id === "123") {
throw ctx.error(400, {
message: "Id is not allowed"
})
}
return {
item: {
id: ctx.body.id
}
}
})
You can also specify custom response headers:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
if (ctx.body.id === "123") {
throw ctx.error(
400,
{ message: "Id is not allowed" },
{ "x-key": "value" } // custom response headers
);
}
return {
item: {
id: ctx.body.id
}
}
})
Or create a redirection:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
if (ctx.body.id === "123") {
throw ctx.redirect("/item/123");
}
return {
item: {
id: ctx.body.id
}
}
})
Or use the ctx.json() to return any object:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
return ctx.json({
item: {
id: ctx.body.id
}
})
})
Finally, you can return a new Response object. In this case, the ctx.setStatus() call will be ignored, as the Response will have completely control over the final status code:
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
return Response.json({
item: {
id: ctx.body.id
}
}, { status: 201 });
})
Note: Please note that when using the
ResponseAPI, your endpoint will not return the JSON object even if you use theResponse.json()helper, you'll always get aResponseas a result.
Endpoints can use middleware by passing the use option to the endpoint. To create a middleware, you can call createMiddleware and pass it a function or an options object and a handler function.
If you return a context object from the middleware, it will be available in the endpoint context.
import { createMiddleware, createEndpoint } from "better-call";
const middleware = createMiddleware(async (ctx) => {
return {
name: "hello"
}
})
const endpoint = createEndpoint("/", {
method: "GET",
use: [middleware],
}, async (ctx) => {
// this will be the context object returned by the middleware with the name property
ctx.context
})
You can create a router by calling createRouter and passing it an array of endpoints. It returns a router object that has a handler method that can be used to serve the endpoints.
import { createRouter } from "better-call"
import { createItem } from "./item"
const router = createRouter({
createItem
})
Bun.serve({
fetch: router.handler
})
Behind the scenes, the router uses rou3 to match the endpoints and invoke the correct endpoint. You can look at the rou3 documentation for more information.
You can create virtual endpoints by completely omitting the path. Virtual endpoints do not get exposed for routing, do not generate OpenAPI docs and cannot be inferred through the RPC client, but they can still be invoked directly:
import { createEndpoint, createRouter } from "better-call";
const endpoint = createEndpoint({
method: "GET",
}, async (ctx) => {
return "ok";
})
const response = await endpoint(); // this works
const router = createRouter({ endpoint })
Bun.serve({
fetch: router.handler // endpoint won't be routed through the router handler
});
You can also create endpoints that are exposed for routing, but that cannot be inferred through the client by using the metadata.scope option:
rpc - the endpoint is exposed to the router, can be invoked directly and is available to the RPC clientserver - the endpoint is exposed to the router, can be invoked directly, but is not available to the clienthttp - the endpoint is only exposed to the routerimport { createEndpoint, createRouter } from "better-call";
const endpoint = createEndpoint("/item", {
method: "GET",
metadata: {
scope: "server"
},
}, async (ctx) => {
return "ok";
})
const response = await endpoint(); // this works
const router = createRouter({
endpoint
})
Bun.serve({
fetch: router.handler // endpoint won't be routed through the router handler
})
routerMiddleware:
A router middleware is similar to an endpoint middleware but it's applied to any path that matches the route. It's like any traditional middleware. You have to pass endpoints to the router middleware as an array.
const routeMiddleware = createEndpoint("/api/**", {
method: "GET",
}, async (ctx) => {
return {
name: "hello"
}
})
const router = createRouter({
createItem
}, {
routerMiddleware: [{
path: "/api/**",
middleware:routeMiddleware
}]
})
basePath: The base path for the router. All paths will be relative to this path.
onError: The router will call this function if an error occurs in the middleware or the endpoint. This function receives the error as a parameter and can return different types of values:
Response object, the router will use it as the HTTP response.APIError, it will be converted to a response; otherwise, it will be re-thrown).throwError setting).const router = createRouter({
/**
* This error handler can be set as async function or not.
*/
onError: async (error) => {
// Log the error
console.error("An error occurred:", error);
// Return a custom response
return new Response(JSON.stringify({ message: "Something went wrong" }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
});
throwError: If true, the router will throw an error if an error occurs in the middleware or the endpoint. If false (default), the router will handle errors internally. This setting is still relevant even when onError is provided, as it determines the behavior when:
onError handler is provided, oronError handler returns void (doesn't return a Response or throw an error)APIError instances, it will convert them to appropriate HTTP responses.const router = createRouter({
throwError: true, // Errors will be propagated to higher-level handlers
onError: (error) => {
// Log the error but let throwError handle it
console.error("An error occurred:", error);
// No return value, so throwError setting will determine behavior
}
});
You can use the node adapter to serve the router with node http server.
import { createRouter } from "better-call";
import { toNodeHandler } from "better-call/node";
import { createItem } from "./item";
import http from "http";
const router = createRouter({
createItem
})
const server = http.createServer(toNodeHandler(router.handler))
better-call comes with a rpc client that can be used to call endpoints from the client. The client wraps over better-fetch so you can pass any options that are supported by better-fetch.
import { createClient } from "better-call/client";
import { router } from "@serve/router";
const client = createClient<typeof router>({
/**
* if you add custom path like `http://
* localhost:3000/api` make sure to add the
* custom path on the router config as well.
*/
baseURL: "http://localhost:3000"
});
const items = await client("/item", {
body: {
id: "123"
}
});
You can also pass object that contains endpoints as a generic type to create client.
If you return a response object from an endpoint, the headers and cookies will be set on the response object. But You can set headers and cookies for the context object.
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
ctx.setHeader("X-Custom-Header", "Hello World")
ctx.setCookie("my-cookie", "hello world")
return {
item: {
id: ctx.body.id
}
}
})
You can also get cookies from the context object.
const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
const cookie = ctx.getCookie("my-cookie")
return {
item: {
id: ctx.body.id
}
}
})
Note: The context object also exposes and allows you to interact with signed cookies via the
ctx.getSignedCookie()andctx.setSignedCookie()helpers.
You can create an endpoint creator by calling createEndpoint.create that will let you apply set of middlewares to all the endpoints created by the creator.
const dbMiddleware = createMiddleware(async (ctx) => {
return {
db: new Database()
}
})
const create = createEndpoint.create({
use: [dbMiddleware]
})
const createItem = create("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
await ctx.context.db.save(ctx.body)
})
Better Call by default generate open api schema for the endpoints and exposes it on /api/reference path using scalar. By default, if you're using zod it'll be able to generate body and query schema.
import { createEndpoint, createRouter } from "better-call"
const createItem = createEndpoint("/item/:id", {
method: "GET",
query: z.object({
id: z.string({
description: "The id of the item"
})
})
}, async (ctx) => {
return {
item: {
id: ctx.query.id
}
}
})
But you can also define custom schema for the open api schema.
import { createEndpoint, createRouter } from "better-call"
const createItem = createEndpoint("/item/:id", {
method: "GET",
query: z.object({
id: z.string({
description: "The id of the item"
})
}),
metadata: {
openapi: {
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: {
type: "string",
description: "The id of the item"
}
}
}
}
}
}
}
}
}, async (ctx) => {
return {
item: {
id: ctx.query.id
}
}
})
You can configure the open api schema by passing the openapi option to the router.
const router = createRouter({
createItem
}, {
openapi: {
disabled: false, //default false
path: "/api/reference", //default /api/reference
scalar: {
title: "My API",
version: "1.0.0",
description: "My API Description",
theme: "dark" //default saturn
}
}
})
MIT
FAQs
Better call is a tiny web framework for creating endpoints that can be invoked as a normal function or mounted to a router to be served by any web standard compatible server (like Bun, node, nextjs, sveltekit...) and also includes a typed RPC client for t
The npm package better-call receives a total of 663,157 weekly downloads. As such, better-call popularity was classified as popular.
We found that better-call demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
CVE disclosures hit a record 48,185 in 2025, driven largely by vulnerabilities in third-party WordPress plugins.

Security News
Socket CEO Feross Aboukhadijeh joins Insecure Agents to discuss CVE remediation and why supply chain attacks require a different security approach.

Security News
Tailwind Labs laid off 75% of its engineering team after revenue dropped 80%, as LLMs redirect traffic away from documentation where developers discover paid products.