Overview of Better Call
mainrou3.repository·main·Indexed 21 days ago
https://github.com/better-auth/better-callA lightweight TypeScript web framework for creating dual-purpose endpoints that function as both local functions and server-side routes. It features a type-safe RPC client, a high-performance router based on rou3, and automatic OpenAPI schema generation. Better Call supports web-standard compatible servers such as Bun, Node.js, Next.js, and SvelteKit, and integrates with standard-schema compatible validation libraries like zod.
rou3.To use Better Call, install the core package via pnpm. Additionally, you must install a validation library that is compatible with standard schema, such as zod.
pnpm i better-call
pnpm i zodEndpoints are the fundamental building blocks of better-call. You can create an endpoint using createEndpoint, which takes a path, an options object (defining method, body/query schemas, etc.), and a handler function.
Endpoints can be used in two ways:
import { createEndpoint } from "better-call"
import { z } from "zod"
// 1. Define the endpoint
export const createItem = createEndpoint("/item", {
method: "POST",
body: z.object({
id: z.string()
})
}, async (ctx) => {
return {
item: {
id: ctx.body.id
}
}
})
// 2. Call it directly as a function
const item = await createItem({
body: {
id: "123"
}
})Better Call utilizes the StandardSchemaV1 interface to support validation libraries that adhere to the Standard Schema specification. This allows the library to work seamlessly with various schema validation tools by providing a unified way to validate unknown input values and retrieve inferred types.
A schema object must include a ~standard property containing the following:
version: Must be 1.vendor: The name of the schema library (e.g., 'zod', 'valibot').validate: A function that accepts unknown and returns a Result<Output> or a Promise<Result<Output>>.types (optional): An object containing the input and output types for TypeScript inference.// Example of what a compatible schema object looks like
const mySchema: StandardSchemaV1 = {
"~standard": {
version: 1,
vendor: 'my-validator',
validate: async (value) => {
// validation logic
return { value: value as Output };
},
types: {
input: unknown,
output: string
}
}
};Middleware in better-call can be applied globally or to specific paths via the routerMiddleware configuration in createRouter.
When a request matches a path defined in routerMiddleware, the middleware is executed before the actual endpoint handler. If a middleware returns a Response object, that response is immediately returned to the client, bypassing the endpoint handler.
Middleware receives a context object containing path, method, headers, params, request, body, query, and context (from routerContext). Note that when running as middleware, the asResponse flag in the context is set to false.
const router = createRouter(endpoints, {
routerMiddleware: [
{
path: '/admin',
middleware: async (ctx) => {
if (!ctx.headers.get('authorization')) {
return new Response('Unauthorized', { status: 401 });
}
}
}
]
});The InputContext type represents the raw input provided to an endpoint or middleware. It is used to define the shape of the data that the framework expects to receive. It is composed of several inferred types based on the provided EndpointOptions or MiddlewareOptions:
body: The validated body content (inferred from StandardSchemaV1 or metadata.$Infer.body).method: The HTTP method (e.g., GET, POST, or *).query: The validated query parameters.params: The URL path parameters (inferred from the path string).request: The raw Request object (if requireRequest is true).headers: The request headers (if requireHeaders is true).Additionally, it includes control flags for the framework:
asResponse: If true, methods like .json() will return a response-like object instead of raw data.returnHeaders: Indicates if headers should be returned.returnStatus: Indicates if the status should be returned.use: An array of middleware to apply.path: The current request path.context: A user-provided object for sharing state.asResponse: If true, methods like .json() will return a response-like object instead of raw data.Middleware is created with createMiddleware. When an endpoint uses a middleware via the use option, any object returned by the middleware handler is attached to ctx.context in the endpoint handler.
import { createMiddleware, createEndpoint } from "better-call";
const authMiddleware = createMiddleware(async (ctx) => {
return { user: { id: "1" } }
})
const endpoint = createEndpoint("/profile", {
method: "GET",
use: [authMiddleware],
}, async (ctx) => {
// Access the middleware return value via ctx.context
const user = ctx.context.user
return { user }
})You can restrict which MIME types are accepted for request bodies using allowedMediaTypes. This can be configured at the Router level (applying to all endpoints) or the Endpoint level (overriding the router). If a request uses a disallowed type, the server returns 415 Unsupported Media Type.
// Router-level restriction
const router = createRouter({ createItem }, {
allowedMediaTypes: ["application/json"]
})
// Endpoint-level override
const uploadFile = createEndpoint("/upload", {
method: "POST",
metadata: {
allowedMediaTypes: ["multipart/form-data"]
}
}, async (ctx) => { ... })Handlers can return various types: a Response object, a plain JavaScript value (which is serialized), or the result of ctx.json().
Use ctx.setStatus(status) to change the success status code.
To return an error, you can:
ctx.error(codeOrStatus, data, headers)APIError instancethrow 400)When mounted to a router, errors are converted to HTTP responses. When called as a function, they are thrown.
import { APIError } from "better-call"
const createItem = createEndpoint("/item", {
method: "POST",
}, async (ctx) => {
// Using ctx.error helper
if (ctx.body.id === "123") {
throw ctx.error("BAD_REQUEST", { message: "Id is not allowed" })
}
// Using APIError class
if (ctx.body.id === "456") {
throw new APIError("BAD_REQUEST", { message: "Id is not allowed" })
}
// Using status code
if (ctx.body.id === "789") {
throw ctx.error(400, { message: "Error" })
}
// Setting custom status
ctx.setStatus(201)
return { success: true }
})The createClient function from better-call/client allows you to call server-side endpoints from a client with full type safety. You should pass the typeof router as a generic to createClient.
Endpoints are called using a path string (e.g., "@post/item" for a POST endpoint at /item).
import type { router } from "./router"
import { createClient } from "better-call/client"
const client = createClient<typeof router>({
baseURL: "http://localhost:3000"
})
// The result follows the pattern: { data: T, error: null | Error }
const { data, error } = await client("@post/item", {
body: {
id: "123"
}
})Better Call automatically generates OpenAPI schemas. By default, it uses zod schemas for body and query to populate the documentation and exposes it at /api/reference using Scalar.
You can configure the OpenAPI settings in the createRouter options:
disabled: Boolean to turn off generation.path: The URL where the documentation is served.scalar: Configuration for the Scalar UI (title, version, description, theme).const router = createRouter({
createItem
}, {
openapi: {
disabled: false,
path: "/api/reference",
scalar: {
title: "My API",
theme: "dark"
}
}
})Endpoints support direct paths, path parameters, and wildcards:
/item/item/:id (accessible via ctx.params.id)/item/**:name (the name parameter captures the remaining path segments)// 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) => {
// ctx.params.name contains the remaining path
})