Integrations
Better Auth storage
Store Better Auth users and sessions in the same PostgreSQL database as your application.
On this page
@hulla/db-better-auth stores Better Auth’s users and sessions in your application’s PostgreSQL database. Continue using Better Auth to handle login and cookies; the adapter connects it to storage.
Install @hulla/db-better-auth@0.1.0 with its peers, @hulla/db and better-auth; see installation. This adapter is tested with Better Auth 1.7.12. For another auth provider, use a credential verifier instead.
Start with the normal schema and config setup. Spread the auth tables into your application schema:
// db/schema.ts
import { defineSchema, defineTable, v } from '@hulla/db/schema'
import { betterAuthTables } from '@hulla/db-better-auth/schema'
export default defineSchema({
...betterAuthTables(),
tasks: defineTable({
title: v.string(),
completed: v.boolean().default(false),
}),
})Generate and explicitly initialize storage using the normal database commands. The schema helper does not import your generated database, open a connection or create tables itself. This lets a new project generate its database before its auth instance exists.
For plugins, extra fields, or renamed models, put Better Auth options in an independent auth-options.ts. Pass the same options to betterAuthTables(authOptions) and betterAuth({ ...authOptions, database: ... }). Keep that module independent of the generated database and auth instance.
The helper discovers additional tables at runtime. Use the static generator below when plugin constructors import server code: query workers can’t bundle those imports. Static tables also preserve precise field types for customized models.
// auth.ts — server only
import { betterAuth } from 'better-auth'
import { hullaAdapter } from '@hulla/db-better-auth'
import { db } from './db/_generated/server'
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!,
database: hullaAdapter(db),
emailAndPassword: { enabled: true },
})Mount auth.handler(request) on your application’s /api/auth/* route using
Better Auth’s normal framework recipe. It accepts a Web Request and returns a
Response; the storage adapter needs no framework-specific counterpart. Existing
Next, Nuxt, SvelteKit, Astro and other application auth routes retain their own
request lifecycle. Direct db calls use the application’s authorization checks.
The adapter supports transactions. A Better Auth transaction callback runs once and rolls back all its database operations if it fails. Internal, database-only single-row operations may retry a confirmed OCC rollback; they never replay a provider callback or an external effect.
If your app also uses reactive query/mutation declarations, add the provider’s
session lookup to the database config. The lazy import avoids a cycle:
auth.ts imports the generated database, which imports this config.
// Inside defineConfig({ database: ..., auth: ... }) in db.config.ts
auth: async (headers) => {
const { auth } = await import('./auth')
const session = await auth.api.getSession({ headers })
return session
? {
key: session.user.id,
identity: { id: session.user.id },
}
: null
}Query/mutation handlers receive this verified ctx.identity. Check it in the
handler or a shared extension before reading protected rows. Auth tables are
ordinary server-side tables; adding them to the schema does not create browser
endpoints or expose their contents.
On the browser, use Better Auth’s existing session state to update the generated
runtime’s cache identity. The example below assumes your ingress forwards
/functions/* to the persistent database service and /api/auth/* to your app.
That makes both APIs same-origin, so the existing session cookie reaches them.
This vanilla client works without a UI framework:
// src/auth-database.ts — browser startup
import { createAuthClient } from 'better-auth/client'
import { dispose, setAuth } from '../db/_generated/client/runtime'
export const authClient = createAuthClient()
const cacheIdentity = (data: typeof authClient.$Infer.Session | null) =>
data
? { key: data.user.id, expiresAt: data.session.expiresAt.getTime() }
: null
// Set database.client.url to "/" when proxying on the same origin.
setAuth(
authClient.getSession().then(({ data, error }) => {
if (error) throw error
return cacheIdentity(data)
})
)Subscribe once for the lifetime of the application:
const stopAuth = authClient.useSession.subscribe(
({ data, isPending, error }) => {
if (isPending) return
setAuth(error ? Promise.reject(error) : cacheIdentity(data))
}
)
// On application teardown:
stopAuth()
await dispose()Import this startup module once from the framework’s browser entrypoint. The relative generated import assumes src/ and db/ are siblings.
Cookies travel separately with same-origin requests. Cross-origin deployments need matching cookie, CORS, and credentials: 'include' settings. Bearer providers can use the request-header callback instead.
Include the active tenant or account in both keys when users can switch between them. Changing the key retires the previous live view; framework bindings reattach automatically. expiresAt is optional.
The adapter implements Better Auth’s schema-generation hook. Its CLI generates a
module at db/auth-schema.ts by default, exporting authTables; import and spread
that object in defineSchema. The generated file depends only on
@hulla/db/schema, so it remains independent of the auth instance and generated
database. The adapter never overwrites an existing file automatically.
For tooling that already has the provider options, the same generator is public:
import { getAuthTables } from 'better-auth/db'
import { generateAuthSchema } from '@hulla/db-better-auth/schema'
import { authOptions } from './auth-options'
const source = generateAuthSchema(getAuthTables(authOptions))
// Write source to db/auth-schema.ts, review it, then regenerate the database.Renamed models/fields, plugin tables, nullable values, Dates, JSON, arrays, enums,
indexes and unique constraints are preserved. Better Auth owns generated opaque
string IDs and function defaults. Numeric IDs, non-primary-key references,
unsupported deletion policies and skipped schema management fail explicitly.
Native Better Auth joins are unsupported; leave advanced.database.joins off to
use its portable fallback. Its offset reads use an ordered prefix and remain
subject to database row/byte budgets; application lists should use cursor paging.