Migrate from Better Auth to Faire Auth
Why Migrate?
Faire Auth is a ground-up rebuild of Better Auth on Hono. It replaces the better-call framework with OpenAPIHono, adds typed per-route middleware, route hooks, DTO transforms, and a plugin architecture designed for composability. If you're already using Better Auth, the database schema is compatible and most concepts transfer directly — but the API surface has changed significantly.
Step 1: Swap the Package
npm install faire-authRemove better-auth from your dependencies:
npm uninstall better-authIf you use the CLI:
npm install @faire-auth/cliStep 2: Update Imports
Replace all better-auth imports with faire-auth:
// Before
import { betterAuth } from "better-auth";
import { createAuthClient } from "better-auth/client";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
// After
import { faireAuth, defineOptions } from "faire-auth";
import { createAuthClient } from "faire-auth/client";
import { createEndpoint, createRoute, createMiddleware, createHook } from "faire-auth/plugins";Plugin imports follow the same pattern:
// Before
import { organization } from "better-auth/plugins";
// After
import { organization } from "faire-auth/plugins";Framework integrations:
// Before
import { toNextJsHandler } from "better-auth/next-js";
import { toNodeHandler } from "better-auth/node";
// After
import { toNextJsHandler } from "faire-auth/next-js";
import { toNodeHandler } from "faire-auth/node";Step 3: Update Server Configuration
The betterAuth() function is replaced by faireAuth(). Use defineOptions() to get type inference for the new middleware, hooks, and DTO options:
// Before
export const auth = betterAuth({
database: new Pool({ connectionString: DATABASE_URL }),
emailAndPassword: { enabled: true },
plugins: [organization(), twoFactor()],
});
// After
import { faireAuth, defineOptions } from "faire-auth";
import { organization, twoFactor } from "faire-auth/plugins";
const cfg = defineOptions({
baseURL: "http://localhost:3000",
database: new Pool({ connectionString: DATABASE_URL }),
emailAndPassword: { enabled: true },
plugins: [organization(), twoFactor()],
});
const auth = faireAuth(cfg);Type Inference with $Infer
Faire Auth expands $Infer (which Better Auth used for Session) with App() and Api() for full type inference across server and client:
const { $Infer } = faireAuth(cfg);
// Get the typed Hono app — pass this to createAuthClient for inference
export const App = $Infer.App(cfg);
// Get the typed server-side API object (keyed by operationId)
export const Api = $Infer.Api(App);
// Session type including plugin fields
type Session = typeof $Infer.Session;Edge Handler
The handler now supports Cloudflare Worker bindings natively:
// Before — Better Auth
export default auth.handler;
// After — Faire Auth supports edge runtimes
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
return auth.handler(request, env, ctx);
},
};Step 4: Update Server-Side API Calls
Server-side API calls now use json: instead of body::
// Before
await auth.api.signInEmail({
body: { email, password },
headers,
});
// After
await auth.api.signInEmail(
{ json: { email, password } },
{ headers },
);This applies to all server-side auth.api.*() calls: signUpEmail, signInEmail, changePassword, changeEmail, setPassword, etc.
Step 5: Update Client Code
The client now uses a curried generic and Hono-style proxy methods:
Client Creation
// Before
const client = createAuthClient({
plugins: [organizationClient()],
});
// After — curried: first call passes the App type, second passes options
import type { App } from "./auth";
const client = createAuthClient<typeof App>()({
plugins: [organizationClient()],
});Client Method Calls
All client methods now use .$post() / .$get() with { json: {...} }:
// Before
await client.signIn.email({ email, password });
await client.signUp.email({ email, password, name });
await client.signOut();
await client.getSession();
await client.updateUser({ name: "New Name" });
await client.signIn.social({ provider: "github" });
// After
await client.signIn.email.$post({ json: { email, password } });
await client.signUp.email.$post({ json: { email, password, name } });
await client.signOut.$post();
await client.getSession.$get({ query: {} });
await client.updateUser.$post({ json: { name: "New Name" } });
await client.signIn.social.$post({ json: { provider: "github" } });Fetch Options
Callbacks like onSuccess and onError move to the second argument under fetchOptions:
// Before
await client.signIn.email({
email,
password,
fetchOptions: {
onSuccess(ctx) { /* ... */ },
},
});
// After
await client.signIn.email.$post(
{ json: { email, password } },
{
fetchOptions: {
onSuccess(ctx) { /* ... */ },
},
},
);New Client Features
// $path — get the route path without the base URL
client.getSession.$path(); // "/get-session"
// $url — get a full URL object
client.getSession.$url(); // URL { href: "http://localhost:3000/api/auth/get-session" }
// $store — reactive session store with manual control
client.$store.notify(); // force session refresh
client.$store.listen("$sessionSignal", () => { /* ... */ });Step 6: Update Plugins
The plugin interface has changed in several ways. Here are real before/after comparisons from built-in plugins.
Hooks-only plugins (e.g. Bearer)
The bearer plugin uses only hooks — no endpoints. The key difference is createAuthMiddleware becomes createHook()() (curried), and hooks handlers are now factory functions that receive options:
// Before — Better Auth bearer plugin
import { createAuthMiddleware } from "@better-auth/core/api";
export const bearer = (options?) => ({
id: "bearer",
hooks: {
before: [{
matcher(context) {
return Boolean(context.request?.headers.get("authorization"));
},
handler: createAuthMiddleware(async (c) => {
const token = c.request?.headers.get("authorization");
// ... convert bearer token to session cookie
const existingHeaders = c.request?.headers as Headers;
return {
context: { headers: newHeaders },
};
}),
}],
},
}) satisfies BetterAuthPlugin;
// After — Faire Auth bearer plugin
import { createHook } from "../../api/factory/middleware";
export const bearer = (options?) => ({
id: "bearer",
hooks: {
before: [{
matcher: (ctx) => Boolean(ctx.req.header("Authorization")),
handler: (_opts) => // factory function receives options
createHook()(async (ctx) => { // curried: createHook()()
const token = ctx.req.header("Authorization")?.replace("Bearer ", "");
// ... convert bearer token to session cookie
ctx.req.raw.headers.append("cookie", `${cookieName}=${signedToken}`);
}),
}],
},
}) satisfies FaireAuthPlugin;Key differences in hooks:
createAuthMiddleware(handler)→(_opts) => createHook()(handler)— handler wrapped in factoryc.request?.headers→ctx.req.header("Authorization")— Hono request APIreturn { context: { headers } }→ctx.req.raw.headers.append(...)— mutate directlyc.context.secret→ctx.get("context").secret— context via Honoget()
Plugins with routes (e.g. Username)
Plugins with endpoints see the biggest change. endpoints becomes routes, and createAuthEndpoint is replaced by createEndpoint + createRoute with explicit OpenAPI schemas:
// Before — Better Auth username plugin
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
export const username = (options?) => ({
id: "username",
endpoints: {
signInUsername: createAuthEndpoint(
"/sign-in/username",
{
method: "POST",
body: z.object({
username: z.string(),
password: z.string(),
rememberMe: z.boolean().optional(),
}),
},
async (ctx) => {
const { username, password } = ctx.body;
// ... validate and sign in
return ctx.json({ token: session.token, user });
},
),
},
// validation ran as a global hook matching by path
hooks: {
before: [{
matcher(context) {
return context.path === "/sign-up/email" || context.path === "/update-user";
},
handler: createAuthMiddleware(async (ctx) => {
// validate username from ctx.body
}),
}],
},
}) satisfies BetterAuthPlugin;
// After — Faire Auth username plugin
import { createEndpoint } from "../../api/factory/endpoint";
import { createRoute, req, res } from "@faire-auth/core/factory";
import { SCHEMAS, Definitions } from "@faire-auth/core/static";
export const username = (options?) => ({
id: "username",
routes: { // "endpoints" → "routes"
signInUsername: createEndpoint(
createRoute({
operationId: "signInUsername", // explicit operationId
method: "post",
path: "/sign-in/username",
request: req().bdy(z.object({ // request schema via builder
username: z.string(),
password: z.string(),
rememberMe: z.boolean().optional(),
})).bld(),
responses: res(SCHEMAS[Definitions.TOKEN_USER].default) // response schema
.err(401, "Invalid username or password")
.bld(),
}),
(authOptions) => async (ctx) => { // handler factory receives options
const { username, password } = ctx.req.valid("json"); // Hono validation
// ... validate and sign in
return ctx.render({ success: true, token, user }, 200);
},
),
},
// validation now uses routeHooks keyed by operationId
routeHooks: {
signUpEmail: shimUsername, // runs after Zod validation, before handler
updateUser: shimUsername, // no path matching needed — keyed by route name
},
}) satisfies FaireAuthPlugin;Key differences in routes:
endpoints→routescreateAuthEndpoint(path, opts, handler)→createEndpoint(createRoute({...}), (opts) => handler)ctx.body→ctx.req.valid("json")— Hono validated inputctx.json(data)→ctx.render(data, status)— Faire Auth render helper- OpenAPI response schemas are explicit via
res().err().bld()builder - Each route gets an
operationIdthat types propagate through to the client
Key differences in validation:
- Global hooks with path matching →
routeHookskeyed by operationId createAuthMiddleware(async (ctx) => {...})→ plain function(result, ctx) => {...}- Route hooks receive the Zod validation result, not the raw request — validation is done for you
- No need for
matcher— the key IS the route
New plugin fields
Faire Auth plugins can declare middleware and routeHooks alongside the existing hooks:
const myPlugin = {
id: "my-plugin",
// Per-route typed Hono middleware (runs in middleware chain)
middleware: {
getSession: createMiddleware()(async (ctx, next) => {
console.log("before getSession");
await next();
}),
},
// Per-route post-validation hooks (keyed by operationId)
routeHooks: {
signUpEmail: (result, ctx) => {
if (result.success) {
console.log("new signup:", result.data.json.email);
}
},
},
} satisfies FaireAuthPlugin;Step 7: Update Global Hooks
Global hooks.before / hooks.after are now factory functions:
// Before — Better Auth
export const auth = betterAuth({
hooks: {
before: createAuthMiddleware(async (ctx) => { /* ... */ }),
after: createAuthMiddleware(async (ctx) => { /* ... */ }),
},
});
// After — Faire Auth
import { createHook } from "faire-auth/plugins";
export const auth = faireAuth({
baseURL: "http://localhost:3000",
hooks: {
before: (options) => createHook()(async (ctx) => { /* ... */ }),
after: (options) => createHook()(async (ctx) => { /* ... */ }),
},
});Step 8: New Options
Faire Auth adds three new top-level options — middleware, routeHooks, and dto — that don't exist in Better Auth. These are optional and your existing config will work without them, but they're the primary way to customize behavior in Faire Auth.
Execution Order
Understanding when each one runs matters. Here's the order for a single request:
Request
→ Global middleware (origin check, rate limit, hooks.before)
→ Per-route middleware (options.middleware + plugin.middleware)
→ Zod validation (request body, query, params validated against schema)
→ Route hook (options.routeHooks + plugin.routeHooks — receives validation result)
→ Route handler (the actual endpoint logic)
→ DTO transform (options.dto — transforms response schemas before serialization)
→ Global hooks.after
ResponseEach layer has a distinct role:
middleware — Gate the request
Per-route Hono middleware, keyed by operationId. Runs before validation in the Hono middleware chain. Use this for access control, logging, rate limiting, or injecting context variables. Middleware from options.middleware, plugin middleware, and the route's own config.middleware are concatenated in that order.
import { createMiddleware } from "faire-auth/plugins";
const cfg = defineOptions({
middleware: {
// Runs before the getSession handler — can reject, log, or set context vars
getSession: createMiddleware()(async (ctx, next) => {
console.log("getSession called by", ctx.req.header("User-Agent"));
await next();
}),
// Require a custom header on sign-up
signUpEmail: createMiddleware()(async (ctx, next) => {
if (!ctx.req.header("X-Signup-Token")) {
return ctx.json({ error: "Missing signup token" }, 403);
}
await next();
}),
},
});Middleware can short-circuit by returning a response without calling next(). It has access to the raw request but not the validated input (that hasn't been parsed yet).
routeHooks — Intercept after validation
Per-route hooks, keyed by operationId. Runs after Zod validation but before the route handler. The hook receives the validation result — either { success: true, data } with the parsed input, or { success: false, error: ZodError }.
const cfg = defineOptions({
routeHooks: {
// Runs after the request body is validated, before the handler
signUpEmail: (result, ctx) => {
if (!result.success) return; // let default Zod error handling take over
const { email } = result.data.json;
if (!email.endsWith("@company.com")) {
return ctx.json({ error: "Only company emails allowed" }, 403);
}
// Can also mutate result.data to transform input before the handler sees it
},
// Reject password changes outside business hours
changePassword: (result, ctx) => {
const hour = new Date().getHours();
if (hour < 9 || hour > 17) {
return ctx.json({ error: "Password changes only during business hours" }, 403);
}
},
},
});Route hooks can return a response to short-circuit, or return nothing to let the handler proceed. They're the right place for business logic validation that depends on the parsed request data.
dto — Shape the response
Response transforms keyed by schema name (user, session, account, etc.). Runs after the handler returns data, transforming it before serialization. DTOs are implemented as Zod .transform() calls on the response schemas, so they apply to both HTTP responses and server-side auth.api.*() calls.
const cfg = defineOptions({
dto: {
// Transform every user object in every response
user: (user) => ({
id: user.id,
name: user.name,
email: user.email,
// createdAt, updatedAt, and any internal fields are stripped
}),
// Transform session objects
session: (session) => ({
id: session.id,
userId: session.userId,
expiresAt: session.expiresAt,
// ipAddress, userAgent stripped from client responses
}),
},
});DTOs are type-threaded — the client's inferred response types reflect the transformed shape, not the raw database model. If your DTO omits createdAt, the client's TypeScript types won't include it either. This is implemented at the Zod schema level via buildSchemas(), which appends .transform() to each schema that has a matching DTO key.
rateLimit.customRules
Typed per-route rate limit rules. Each rule receives a typed HonoRequest for the specific route path:
const cfg = defineOptions({
rateLimit: {
enabled: true,
customRules: {
"/sign-in/email": (req) => {
// return false to skip rate limiting for this request
return true;
},
},
},
});Step 9: Database
Faire Auth uses the same database schema as Better Auth. No migration is needed — your existing tables, sessions, and accounts will work without changes.
If you've added custom fields via plugins, regenerate your schema:
npx @faire-auth/cli generateSummary of Breaking Changes
| Better Auth | Faire Auth |
|---|---|
betterAuth() | faireAuth() + defineOptions() |
body: { ... } in server API | json: { ... } in server API |
client.method({ ... }) | client.method.$post({ json: { ... } }) |
createAuthClient(opts) | createAuthClient<App>()(opts) |
createAuthEndpoint(path, opts, handler) | createEndpoint(createRoute({...}), (opts) => handler) |
createAuthMiddleware(handler) | createHook()(handler) / createMiddleware()(handler) |
endpoints: { ... } on plugins | routes: { ... } on plugins |
hooks.before: handler | hooks.before: (opts) => handler (factory) |
Built on better-call | Built on Hono / OpenAPIHono |
Need Help?
If you run into issues during migration, check the docs or open an issue on GitHub.