better-call: endpoints you can call directly or mount to a router
a tiny web framework for typescript
At a glance
- What is it?
- Better Call is a small TypeScript framework whose central idea is that an endpoint is a function you may call in process, mount to a router for HTTP, or reach from the browser through a typed client. The README spends most of its length on media types and validation, which is where the interesting decisions are for anyone putting it in front of a real API.
- Who is it for?
- Adopt better-call if you want one definition of an endpoint that serves as a plain function in tests and on the server, and as an HTTP route with the same types on the client, since that is the whole point of the design. Do not adopt it if you need a mature framework with a large ecosystem, because this one is deliberately tiny and young, with the version still in the one series.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 2 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
One endpoint, two ways to reach it, and a client that knows the types
Better Call is described as 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, such as Bun, node, nextjs or sveltekit, and it also includes a typed RPC client for type-safe client-side invocation of those endpoints.
That sentence is the whole design. An endpoint is defined once, with a path, some options and a handler, and from that single definition you get a function you can call in process, an HTTP route you can mount, and a client method the compiler knows the shape of.
The three modes are worth separating, because they have different costs. Calling the endpoint directly means no serialisation, no HTTP and no status code, which is what you want in a test or in a server that composes two endpoints. Mounting it means the same code runs as a route, and the framework's media type and validation behaviour applies. The client means the browser gets types generated from the router type rather than a hand written interface, and the README's example shows the call and the result, where the response is wrapped in a data and error pair.
The framework is built for TypeScript and comes with a very high performance router based on the rou3 matcher. The installation is one package, plus a validation library.
The project is MIT licensed, the last push was on 2026-10-21, and the recent releases are 1.4.2 on 2026-10-08 with two more patch releases on 2026-10-19, which is the cadence of a young project that is being used in anger.
createEndpoint takes a path, options and a handler
The building block is the endpoint, and the README's first example shows the whole shape of the API in one block.
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
}
}
})The options object carries the method and the schemas, and the handler receives a context object from which the validated values are read. In this example the body is a schema object with a single string field, and the handler reads it from the context, so the value is already typed and already validated when it arrives.
That validation is delegated, not implemented. The install instructions say to make sure you have a standard schema compatible validation library installed, and the example installs zod. The body option accepts a standard schema, and the query option does the same for the query string. If the request does not match the schema, the endpoint throws a validation error, and if the endpoint is mounted to a router, the response is a 400 with the error details.
The method option accepts a single method or an array of them, and there is a rule about calling an endpoint with several methods directly rather than over HTTP: the method is then optional and defaults to the first method in the array. That default is a small convenience with a real consequence, because a direct call that forgets the method silently behaves as the first method rather than failing.
Paths, parameters and a wildcard that eats the rest
The path handling covers three cases, and the README gives one example of each.
A direct path is the plain case. A path with parameters uses a colon segment, and the value is read from the context parameters, so a path with an identifier segment gives the handler a parameter object with that key.
A path with wildcards uses the double star form, and the README notes that the name will be the remaining path, so the handler can read the tail of the URL from the context parameters. That is the pattern for a catch-all route, and it is the reason a path like an item followed by a wildcard can serve a nested resource without the framework needing to know the shape of what comes after it.
// path with parameters
const endpoint = createEndpoint("/item/:id", {
method: "GET",
}, async (ctx) => {
return {
item: {
id: ctx.params.id
}
}
})There is a subtlety in the order of these three forms. The colon form matches one segment and the wildcard matches the remainder, so a route with both has to be interpreted with the wildcard last, and the router based on rou3 is what decides that. For a framework this small, the path syntax is one of the few places where behaviour is worth checking against your own expectations, because a route that matches more than you intended is the kind of bug that shows up as an authorisation problem rather than a 404.
Media types are parsed for you, and can be locked down
The longest section of the README is about media types, and it is the part that tells you what the framework does with a request body.
By default all media types are accepted, but only a handful have built-in parsing support. JSON and the custom JSON suffixes from the media type registry are parsed into a plain object. Form url-encoded and multipart form data are parsed into a form data object. Plain text becomes a string. Binary data becomes an array buffer. PDFs and the image and video families become a blob, and the streaming type becomes a readable stream. Anything unrecognised is parsed as a plain string.
The return path is symmetric: returning a supported type from a handler serialises it with the correct content type.
Then there is the restriction side, through the allowed media types option. It can be set at the router level, where it applies to every endpoint in that router, or at the endpoint level, where the endpoint setting takes precedence. A request with a disallowed media type gets a 415 error.
const router = createRouter({
createItem,
updateItem
}, {
// All endpoints in this router will only accept JSON
allowedMediaTypes: ["application/json"]
})The README includes a caution that is easy to skim past. Using the option does not add parsing support for new media types, it only restricts the media types that are already supported. So the option is a narrowing device, and a project that needs a body type the framework does not know about still has to handle it inside the handler.
A workspace set up like a library that intends to be depended on
The repository is a pnpm workspace with the packages in one directory and an example application in another, and the root project file is a checklist of everything a serious TypeScript library tends to grow.
The formatter and linter is one tool rather than three. The lint script runs a single check across the repository with warnings treated as errors, the fix script can apply unsafe fixes, and a separate script runs a spell checker over the tree, with a configuration file for the word list. Dependencies are checked with a strict unused dependency and export analysis, and there are separate scripts for linting each package and for a type check inside every package.
The publishing side is taken seriously too. There is a package lint script, a tool that checks the shape of the published packages, a tool that checks whether the type definitions are correct for the way they are consumed, and a tool that publishes a preview version of the packages to a temporary registry on every change, which is how a library offers a try-before-install link.
The build is a bundler driven per package, the type check is a project build, the tests run per package with a coverage report, and the version bump has its own configuration and its own tool. There is a lefthook file for the git hooks, a spelling word list, an unused file checker, and a serve script that runs the example with hot reload.
bun --hot example/hello.tsThe example directory is worth a look, because it contains a plain script and a bundle configuration, which is the minimum needed to see the framework used outside the test suite.
What it is for, and where the edges are
A fair reading of the project is that it is a small piece of the better-auth ecosystem that generalises one idea: an endpoint should be a function with a schema, and everything else should follow from that.
For a team building an API in TypeScript, the appeal is that the same definition serves the server and the browser, and that the types come from the router rather than from a generated client or a hand written interface. The cost is that this is a young library, still at version 1.4 in October 2026, from a project that describes itself as tiny, with the API surface defined by a readme rather than by a large body of prior art.
Two dependencies are worth deciding on early. The first is validation, where the framework delegates to any library implementing the standard schema interface, and the README suggests zod while the workspace also carries an alternative validator for testing the interface itself. The second is the router, which is a performance-oriented matcher rather than a general routing layer with middleware, nesting and file based routing.
The media type behaviour is the third thing to understand, because it decides how much work your handlers do. A JSON API needs nothing extra, but a service that accepts uploads or streams has to know that a form body arrives as a form data object and an octet stream arrives as an array buffer, and a body type outside the built-in list arrives as a string, which is a quiet failure mode if you expected an object.
So the decision is roughly this. If you want one typed definition of an endpoint shared by server and client, and you are comfortable with a small surface, this is a good fit. If you want a framework with a plugin ecosystem, a file based router and a large body of documentation, it is not that, and the README is candid enough to let you see that before you install it.
Editorial conclusion
Adopt better-call if you want one definition of an endpoint that serves as a plain function in tests and on the server, and as an HTTP route with the same types on the client, since that is the whole point of the design. Do not adopt it if you need a mature framework with a large ecosystem, because this one is deliberately tiny and young, with the version still in the one series. Before you build on it, pick a validation library that implements the standard schema interface, because the body and query validation is delegated to it, and decide how you want request bodies parsed, since the built-in media type support covers a specific list and anything else arrives as a plain string.
Frequently asked questions
What is better-call and what does it give me?
It is a tiny TypeScript framework for creating endpoints that can be invoked as a normal function or mounted to a router and served by any web standard compatible server, and it also ships a typed RPC client for type-safe client-side calls. The router is built on the rou3 matcher and the package is installed with a single command.
How does better-call validate request bodies and query strings?
The body and query options accept a standard schema, so you install a compatible validation library such as zod. A request that does not match the schema throws a validation error, and if the endpoint is mounted to a router the response is a 400 with the error details.
Which request media types does better-call parse?
JSON and custom JSON suffixes are parsed as a plain object, form url-encoded and multipart form data as a form data object, plain text as a string, binary data as an array buffer, PDFs and image and video types as a blob, the streaming type as a readable stream, and any unrecognised type as a plain string. Returning one of these types from a handler sets the matching content type.
Can better-call restrict which media types an endpoint accepts?
Yes, through the allowed media types option, which can be set at the router level or per endpoint, with the endpoint setting taking precedence. A disallowed media type returns a 415 error, and the README notes that the option only restricts the media types already supported and does not add parsing for new ones.
How is the better-call repository built and tested?
It is a pnpm workspace whose root scripts lint with one checker and a spell checker, check dependencies strictly, type check every package, build and test each package, and publish preview package versions. The example application can be run with hot reload through a serve script, and the version is bumped with a dedicated tool and configuration.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/better-auth-better-call)Was this useful?