better-call
By (author)Better Auth
Read Me
Better Call
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.
Install
pnpm i better-call
Make sure to install standard schema compatible validation library like zod.
pnpm i zod
Usage
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 }
Endpoint
Endpoints are building blocks of better-call.
Path
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
})
Body Schema
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
}
}
})
Query Schema
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
}
}
})
Method
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" })
Media types
By default, all media types are accepted, but only a handful of them have a built-in support:
application/jsonand custom json suffixes are parsed as a JSON (plain) objectapplication/x-www-form-urlencodedandmultipart/form-dataare parsed as aFormDataobjecttext/plainis parsed as a plain stringapplication/octet-streamis parsed as anArrayBufferapplication/pdf,image/*andvideo/*are parsed asBlobapplication/streamis parsed asReadableStream- Any other media type that is not recognized, will be parsed as a plain string.
Similarly, returning a supported type from a handler will properly serialize it with the correct Content-Type as specified above.
Allowed Media Types
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).
Require Headers
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()
})
Require Request
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()
})
Handler
This is the function that will be invoked when the endpoint is called. The signature is:
const handler = async (ctx) => response;
Where ctx is:
- The context object containing the
request,headers,body,query,paramsand 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:
- a
Responseobject - any javascript value:
string,number,boolean, anobjector anarray - the return value of the
ctx.json()helper
Below, we document all the ways in which you can create a response in your handler:
Returning a response
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