Better Auth

Learn how to sign users in with Better Auth and connect its session to Jazz.

Better Auth is a self-hosted auth framework. It runs in the same backend as Jazz, so authenticate asks it directly who the request's session cookie belongs to. No JWT plugin, JWKS or bearer tokens are involved.

Server

jazz.server.ts
import { betterAuth } from "better-auth";
import { createJazz, sqlite } from "jazz-tools/backend";
import { app } from "./schema";
import permissions from "./permissions";
​
export const auth = betterAuth({
// database: your own, or Jazz itself (see "Store Better Auth's tables in Jazz" below)
emailAndPassword: { enabled: true },
});
​
export const jazz = createJazz({
app,
permissions,
store: sqlite({ path: "./data/jazz" }),
// The Better Auth session cookie decides who the user is.
authenticate: async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
return session ? { userId: session.user.id } : null;
},
});

Mount Jazz under /jazz next to Better Auth's own handler, which serves /api/auth/* by default. In Next.js:

app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/jazz.server";
​
export const { GET, POST } = toNextJsHandler(auth);

For the /jazz route and other frameworks, see Your Own Server. Pass anything your policies need, such as the admin plugin's role, as claims.

Act as the signed-in user

In a route handler, act as the signed-in user with jazz.forReqUser(request), which runs the same authenticate:

messages.ts
import { jazz } from "./jazz.server";
import { app } from "./schema";
​
export async function messages(request: Request) {
const db = await jazz.forReqUser(request);
return Response.json(await db.all(app.messages, { tier: "remote" }));
}

This keeps the signed-in user's policies and authorship without switching your main backend's own identity. For account-owned rows, compare ownership against session.user in permissions; a Better Auth user ID is not a Jazz account UUID. Never accept a raw account ID from a request as authority.

Client

The plain Better Auth client is enough. Import it from your framework's entry: better-auth/react, better-auth/vue, better-auth/svelte, or better-auth/client for plain TypeScript.

src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react"; // or "better-auth/vue", "better-auth/svelte"
​
export const authClient = createAuthClient();

Behind a dev server proxy

When a separate frontend dev server (Vite, say) proxies /api/auth and /jazz to your backend, Better Auth sees requests from the dev server's origin and answers 403 INVALID_ORIGIN. Add that origin to trustedOrigins:

export const auth = betterAuth({
trustedOrigins: ["http://localhost:5173"],
// ...
});

Connecting to Jazz

JazzProvider asks your backend who is signed in, so forms only call Better Auth's sign-up or sign-in and then reauthenticate(). Jazz owns startup, account changes and error recovery, and also checks again on start, on focus and when another tab signs in or out.

App.tsx
import { JazzProvider, useJazzAuth } from "jazz-tools/react";
import { authClient } from "./auth-client";
​
function Root() {
return (
<JazzProvider signedOut={<SignIn />}>
<App />
</JazzProvider>
);
}
​
function SignIn() {
const { reauthenticate } = useJazzAuth();
async function signIn(email: string, password: string) {
await authClient.signIn.email({ email, password });
await reauthenticate();
}
// ...your form calls signIn
}

For a full working example, including Better Auth's tables stored in Jazz, see the Better Auth chat example.

Note

If your users start as local-first guests and sign up later, call linkWithBackend() after Better Auth's sign-up so they keep their data. See Other / Custom.

Store Better Auth's tables in Jazz

The Jazz Better Auth adapter lets Better Auth keep its users, credentials and sessions in the same Jazz app as your own data, so one main backend serves both. For general Better Auth setup (route handlers, client, plugins), see the Better Auth documentation.

Generate the tables

Better Auth tables live in a generated schema-better-auth/ module that your app schema spreads into its own table map. This keeps Better Auth's tables in the same Jazz app as your own data, so a single backend context handles both.

Start with shared Better Auth options.

auth-options.ts
export const authOptions = {
baseURL: process.env.BETTER_AUTH_URL!,
secret: process.env.BETTER_AUTH_SECRET!,
emailAndPassword: { enabled: true },
};

Set BETTER_AUTH_URL to your application server's origin and BETTER_AUTH_SECRET to a private Better Auth secret. Keep plugins, additional fields, and model customizations in these shared options so generation and runtime use the same configuration.

Create a separate configuration for generation. It does not import your app schema or open a Jazz session, so it works before either the generated file or Jazz exists. The CLI only requests schema generation; the database callback is deliberately unavailable.

auth-generate.ts
import { betterAuth } from "better-auth";
import { schema as s } from "jazz-tools";
import { jazzAdapter } from "jazz-tools/better-auth-adapter";
import { authOptions } from "./auth-options";
​
export const auth = betterAuth({
...authOptions,
database: jazzAdapter({
db: async () => {
throw new Error("The generation config cannot query the database");
},
schema: s.defineApp({}, { migrations: "none" }).wasmSchema,
}),
});

Generate with the current Better Auth CLI:

npx auth@latest generate \
--config ./auth-generate.ts \
--output ./schema-better-auth/schema.ts \
--yes

--yes writes the output without an interactive confirmation. Review the generated diff when regenerating an existing module.

Import the generated tables in your app's schema.ts and merge them into the app definition:

schema.ts
import { schema as s } from "jazz-tools";
import { migrations } from "./migrations";
import { schema as betterauthSchema } from "./schema-better-auth/schema";
​
const schema = {
...betterauthSchema,
messages: s.table(
{
author_name: s.string(),
chat_id: s.string(),
text: s.string(),
sent_at: s.timestamp(),
},
{},
),
// ...your own tables
};
​
type AppSchema = s.Schema<typeof schema>;
export const app: s.App<AppSchema> = s.defineApp(schema, { migrations });

The generated schema-better-auth/schema.ts also ships a permissions export that denies all operations on the Better Auth tables. Merge it with your own permissions so regular client sessions can't read or write Better Auth rows — the adapter itself uses jazz.db, which has backend permissions.

permissions.ts
import { definePermissions } from "jazz-tools/permissions";
import { permissions as betterAuthPermissions } from "./schema-better-auth/schema";
import { app } from "./schema";
​
const appPermissions = definePermissions(app, ({ policy }) => {
// Define policies for your own tables.
policy.messages.allowRead.always();
});
​
export default {
...betterAuthPermissions,
...appPermissions,
};

After adding both files, validate the merged schema and permissions:

npx jazz-tools validate

Use Jazz as the database

Create Jazz in your backend (Main Backend), then pass jazz.db to jazzAdapter(...) as the database in your Better Auth config. Point both db and schema at the merged app, not at the generated module directly, so Better Auth and your own tables share a single Jazz app. authenticate asks the same Better Auth instance who a request's session cookie belongs to.

auth.ts
import { betterAuth } from "better-auth";
import { createJazz, sqlite } from "jazz-tools/backend";
import { jazzAdapter } from "jazz-tools/better-auth-adapter";
import { app } from "./schema";
import permissions from "./permissions";
import { authOptions } from "./auth-options";
​
export const jazz = createJazz({
app,
permissions,
store: sqlite({ path: "./data/jazz" }), // or vercel() / remote(), as for any backend
authenticate: async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
return session ? { userId: session.user.id } : null;
},
});
​
export const auth = betterAuth({
...authOptions,
database: jazzAdapter({
db: async () => jazz.db,
schema: app.wasmSchema,
}),
});
OptionDescription
dbA function returning Db or Promise<Db>, such as jazz.db. Use the merged app, not authSchema.
schemaTypically app.wasmSchema from your merged schema. A raw s.App is also accepted.
debugLogsBetter Auth adapter debug logging controls.
usePluralWhether Better Auth model names should use plural table names.
prefixTable name prefix (defaults to "better_auth_").
Leave Better Auth joins disabled

The Jazz adapter does not support Better Auth experimental joins yet, so do not enable experimental.joins.

Compatibility

The adapter is currently aligned with Better Auth 1.7.1.

Plugin/FeatureCompatibility
Email & Password auth✅
Social Provider auth✅
Email OTP✅
Compatibility scope

This section reflects the adapter behavior currently covered by this repo's Better Auth integration and tests. If you add plugins that introduce extra tables or custom schema fields, regenerate schema-better-auth/schema.ts and re-run the Jazz schema workflow.