better-call

By (author)Better Auth

Tech stack:
TS
Unclaimed library. This page was created automatically because AI-built apps on Devmarketplace use this open-source package. Are you the maintainer? Contact us to claim this page and showcase your work.

a tiny web framework for typescript
AI Contributions:0
Published At:June 11, 2026
Files Included:0 file(s)
Updated At:July 22, 2026

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/json and custom json suffixes are parsed as a JSON (plain) object
  • application/x-www-form-urlencoded and multipart/form-data are parsed as a FormData object
  • text/plain is parsed as a plain string
  • application/octet-stream is parsed as an ArrayBuffer
  • application/pdf, image/* and video/* are parsed as Blob
  • application/stream is parsed as ReadableStream
  • 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-8 will match application/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, 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:

  • a Response object
  • any javascript value: string, number, boolean, an object or an array
  • 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

Loading

Loading

Loading

More like this View all

Unclaimed librarybetter-fetch

Advanced fetch wrapper for TypeScript

  • 0

    Ratings

  • 0

    Files

    • TS

    Tech stack

Unclaimed libraryfullstack-prisma-nextjs-blog

Fullstack Blog with Next.js and Prisma

  • 0

    Ratings

  • 0

    Files

    • TS

    Tech stack

Zod board ops + narration steps

Validate AI whiteboard teach steps with Zod, then apply add_text/shape/label and exclusive highlights to a scene. Safe for SVG or Excalidraw — never draw unvalidated ops.

  • 0

    Ratings

  • 4

    Files

    • TS

    Tech stack