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-auth

Remove better-auth from your dependencies:

npm uninstall better-auth

If you use the CLI:

npm install @faire-auth/cli

Step 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 factory
  • c.request?.headersctx.req.header("Authorization") — Hono request API
  • return { context: { headers } }ctx.req.raw.headers.append(...) — mutate directly
  • c.context.secretctx.get("context").secret — context via Hono get()

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:

  • endpointsroutes
  • createAuthEndpoint(path, opts, handler)createEndpoint(createRoute({...}), (opts) => handler)
  • ctx.bodyctx.req.valid("json") — Hono validated input
  • ctx.json(data)ctx.render(data, status) — Faire Auth render helper
  • OpenAPI response schemas are explicit via res().err().bld() builder
  • Each route gets an operationId that types propagate through to the client

Key differences in validation:

  • Global hooks with path matching → routeHooks keyed 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
Response

Each 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 generate

Summary of Breaking Changes

Better AuthFaire Auth
betterAuth()faireAuth() + defineOptions()
body: { ... } in server APIjson: { ... } 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 pluginsroutes: { ... } on plugins
hooks.before: handlerhooks.before: (opts) => handler (factory)
Built on better-callBuilt on Hono / OpenAPIHono

Need Help?

If you run into issues during migration, check the docs or open an issue on GitHub.