payload-better-auth
v0.9.1
/

Better Auth for Payload CMS

Seamless integration between Better Auth and Payload. Use Payload collections as your auth database with auto-generated schemas, session management, and admin UI components.

Upgrading to 0.9? A second hardening pass (correctness + robustness). Breaking bits: for passkey sign-in on the admin login, point admin.loginViewComponent at @delmaredigital/payload-better-auth/components/login-passkey#LoginViewWrapperWithPasskey (the default login view no longer imports the optional passkey peer); starts_with/ends_with are now correctly anchored; a comma role string is a single role (use an array); and password changes go through Better Auth's changePassword (the access helper no longer verifies passwords). Full details in the CHANGELOG.

Upgrading to 0.8? Security-hardening release with breaking changes. Highlights:

1. Roles are now assigned server-side on sign-up. The admin login form no longer sends a role, and the first-user-admin hooks ignore any client-supplied role for non-first users. If you configured role as an input-writable Better Auth additionalField, set it to input: false and control the default self-sign-up role via firstUserAdmin: { defaultRole }. This closes a privilege-escalation path where a client could POST { role: 'admin' } to the sign-up endpoint.

2. API-key scope enforcement. Keys sent via the x-api-key header are now scope-checked (previously they could be treated as a full session under allowSessionOrPermission / allowAuthenticatedUsers).

3. 2FA QR codes render locally. The TOTP secret is no longer sent to a third-party QR service — QR codes are generated in the browser. Requires the new qrcode.react dependency (installed automatically).

4. Requires Node ≥ 20.9 and caps peer ranges to the tested majors. See the CHANGELOG for full migration instructions.

Installation

Requirements

DependencyVersion
payload≥ 3.69.0
@payloadcms/next≥ 3.69.0
@payloadcms/ui≥ 3.69.0
better-auth≥ 1.6.0 (1.6.23+ recommended)
next≥ 15.5.16
react≥ 19.2.1

Install

pnpm add @delmaredigital/payload-better-auth better-auth

Optional plugins (install only what you use):

pnpm add @better-auth/passkey
pnpm add @better-auth/api-key

Environment Variables

bash# Required
BETTER_AUTH_SECRET=your-secret-key-min-32-chars

# Optional — only needed if not using the getBaseUrl() helper
BETTER_AUTH_URL=http://localhost:3000

# OAuth Providers (if using social login)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...

Vercel Deployment: Use a getBaseUrl() helper to automatically handle local dev, Vercel preview, and production URLs:

ts// src/lib/auth/getBaseUrl.ts
export function getBaseUrl() {
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
  if (process.env.BETTER_AUTH_URL) return process.env.BETTER_AUTH_URL
  return 'http://localhost:3000'
}

Quick Start

Create Your Auth Configuration

ts// src/lib/auth/config.ts
import type { BetterAuthOptions } from 'better-auth'

export const betterAuthOptions: Partial<BetterAuthOptions> = {
  user: {
    additionalFields: {
      // input: false keeps `role` server-only — clients cannot set it at sign-up.
      role: { type: 'string', defaultValue: 'user', input: false },
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
  },
  emailAndPassword: { enabled: true },
}

Create Your Users Collection

ts// src/collections/Users/index.ts
import type { CollectionConfig } from 'payload'
import { betterAuthStrategy } from '@delmaredigital/payload-better-auth'

export const Users: CollectionConfig = {
  slug: 'users',
  auth: {
    disableLocalStrategy: true,
    strategies: [betterAuthStrategy()],
  },
  access: {
    read: ({ req }) => {
      if (!req.user) return false
      if (req.user.role === 'admin') return true
      return { id: { equals: req.user.id } }
    },
    admin: ({ req }) => req.user?.role === 'admin',
  },
  fields: [
    { name: 'email', type: 'email', required: true, unique: true },
    { name: 'emailVerified', type: 'checkbox', defaultValue: false },
    { name: 'name', type: 'text' },
    { name: 'image', type: 'text' },
    {
      name: 'role',
      type: 'select',
      defaultValue: 'user',
      options: [
        { label: 'User', value: 'user' },
        { label: 'Admin', value: 'admin' },
      ],
    },
  ],
}

Plugin-specific fields (e.g., twoFactorEnabled for 2FA) are automatically added to your Users collection by betterAuthCollections().

Configure Payload (Postgres)

For MongoDB, see MongoDB Setup.

ts// src/payload.config.ts
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { betterAuth } from 'better-auth'
import {
  betterAuthCollections,
  createBetterAuthPlugin,
  payloadAdapter,
} from '@delmaredigital/payload-better-auth'

export default buildConfig({
  collections: [Users],
  plugins: [
    betterAuthCollections({
      betterAuthOptions,
      skipCollections: ['user'],
    }),
    createBetterAuthPlugin({
      createAuth: (payload) =>
        betterAuth({
          ...betterAuthOptions,
          database: payloadAdapter({ payloadClient: payload }),
          advanced: { database: { generateId: 'serial' } },
          baseURL: baseUrl,
          secret: process.env.BETTER_AUTH_SECRET,
          trustedOrigins: [baseUrl],
        }),
    }),
  ],
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL },
  }),
})

Client-Side Auth

Create a client by listing the plugins you need. For typed plugin methods (e.g. client.twoFactor.verifyTotp), list each plugin inline:

ts// src/lib/auth/client.ts
'use client'

import { createAuthClient, twoFactorClient } from '@delmaredigital/payload-better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'

export const authClient = createAuthClient({
  plugins: [twoFactorClient(), passkeyClient()],
})

export const { useSession, signIn, signUp, signOut, twoFactor, passkey } = authClient

Since v0.7.0, payloadAuthPlugins and createPayloadAuthClient() are typed conservatively (BetterAuthClientPlugin[]) to keep generated declaration files portable — they work at runtime, but plugin methods like twoFactor.verifyTotp won't be typed on the returned client. List plugins inline (as above) when you need typed plugin methods.

Minimal setup (no passkeys or optional plugins)
tsimport { createPayloadAuthClient } from '@delmaredigital/payload-better-auth/client'

export const authClient = createPayloadAuthClient()
export const { useSession, signIn, signUp, signOut, twoFactor } = authClient
With multiple optional plugins
tsimport { createAuthClient, payloadAuthPlugins } from '@delmaredigital/payload-better-auth/client'
import { passkeyClient } from '@better-auth/passkey/client'
import { apiKeyClient } from '@better-auth/api-key/client'
import { stripeClient } from '@better-auth/stripe/client'

export const authClient = createAuthClient({
  plugins: [...payloadAuthPlugins, passkeyClient(), apiKeyClient(), stripeClient({ subscription: true })],
})

Server-Side Session

tsimport { headers } from 'next/headers'
import { getPayload } from 'payload'
import { getServerSession } from '@delmaredigital/payload-better-auth'

export default async function Dashboard() {
  const payload = await getPayload({ config })
  const headersList = await headers()
  const session = await getServerSession(payload, headersList)

  if (!session) { redirect('/login') }

  return <div>Hello {session.user.name}</div>
}

That's it! The plugin automatically registers auth API endpoints at /api/auth/*, injects admin UI components, and handles session management.

MongoDB Setup

The adapter auto-detects MongoDB and configures itself accordingly. No special adapter configuration is needed.

Key Differences from Postgres

Postgres (default)MongoDB
ID type'number' (SERIAL)'text' (ObjectId strings)
generateIdSet to 'serial'Do not set
betterAuthStrategyDefaultidType: 'text'
Full MongoDB Payload config example
ts// src/payload.config.ts
import { mongooseAdapter } from '@payloadcms/db-mongodb'

export default buildConfig({
  collections: [Users],
  plugins: [
    betterAuthCollections({ betterAuthOptions, skipCollections: ['user'] }),
    createBetterAuthPlugin({
      createAuth: (payload) =>
        betterAuth({
          ...betterAuthOptions,
          database: payloadAdapter({ payloadClient: payload }),
          // Do NOT set advanced.database.generateId
          secret: process.env.BETTER_AUTH_SECRET,
          trustedOrigins: ['http://localhost:3000'],
        }),
    }),
  ],
  db: mongooseAdapter({ url: process.env.DATABASE_URI! }),
})
MongoDB Users collection & session helpers
ts// Users collection
strategies: [betterAuthStrategy({ idType: 'text' })]

// Session helpers
export const { getServerSession, getServerUser } = createSessionHelpers<User>({
  idType: 'text',
})
Migrating from Postgres to MongoDB
  1. Remove generateId: 'serial' from your Better Auth config
  2. Change betterAuthStrategy() to use idType: 'text'
  3. Update createSessionHelpers to idType: 'text'
  4. Remove explicit idType: 'number' from adapterConfig
  5. Switch Payload's database adapter to mongooseAdapter
  6. Remove idFieldsAllowlist/idFieldsBlocklist if set

API Reference

payloadAdapter(config)

Creates a Better Auth database adapter that uses Payload collections. Uses Better Auth's createAdapterFactory for schema-aware transformations.

tspayloadAdapter({
  payloadClient: payload,
  adapterConfig: {
    enableDebugLogs: false,
    idType: 'number',
  },
})
OptionTypeDescription
payloadClientBasePayload | () => PromisePayload instance or factory function
adapterConfig.enableDebugLogsbooleanEnable debug logging (default: false)
adapterConfig.dbType'postgres' | 'mongodb' | 'sqlite'Database type (auto-detected)
adapterConfig.idType'number' | 'text'ID type (auto-detected)
Custom collection names

By default, the adapter uses standard collection names. Only use modelName to customize:

tsbetterAuth({
  database: payloadAdapter({ payloadClient: payload }),
  user: { modelName: 'member' },        // 'users' → 'members'
  session: { modelName: 'auth_session' }, // 'sessions' → 'auth_sessions'
})

betterAuthCollections(options)

Payload plugin that auto-generates collections from Better Auth schema.

OptionTypeDescription
betterAuthOptionsBetterAuthOptionsYour Better Auth options
skipCollectionsstring[]Collections to skip (default: ['user'])
adminGroupstringAdmin panel group name (default: 'Auth')
accessCollectionConfig['access']Custom access control for generated collections
usePluralbooleanPluralize slugs (default: true)
configureSaveToJWTbooleanAuto-configure saveToJWT (default: true)
firstUserAdminboolean | objectMake first user admin (default: true)
customizeCollection(key, config) => configCustomize generated collections

Custom Access Caution: The access option completely replaces the default access object. You must handle all access types explicitly.

First User Admin configuration
ts// Customize roles
betterAuthCollections({
  betterAuthOptions,
  firstUserAdmin: {
    adminRole: 'super-admin',
    defaultRole: 'member',
    roleField: 'userRole',
  },
})

// Disable
betterAuthCollections({ betterAuthOptions, firstUserAdmin: false })
Collection customization example
tsbetterAuthCollections({
  betterAuthOptions,
  customizeCollection: (modelKey, collection) => {
    if (modelKey === 'session') {
      return { ...collection, hooks: { afterDelete: [cleanupExpiredSessions] } }
    }
    return collection
  },
})

createBetterAuthPlugin(options)

Payload plugin that initializes Better Auth during Payload's onInit.

OptionTypeDefaultDescription
createAuth(payload) => AuthrequiredFactory function for Better Auth instance
authBasePathstring'/auth'Base path for auth endpoints
autoRegisterEndpointsbooleantrueAuto-register auth API endpoints
autoInjectAdminComponentsbooleantrueAuto-inject admin components
Full admin options reference
OptionDefaultDescription
admin.disableLogoutButtonfalseDisable logout button injection
admin.disableBeforeLoginfalseDisable BeforeLogin redirect
admin.disableLoginViewfalseDisable login view injection
admin.login.title'Login'Custom login page title
admin.login.afterLoginPath'/admin'Redirect path after login
admin.login.requiredRole'admin'Required role(s) for admin. null to disable.
admin.login.requireAllRolesfalseRequire ALL roles instead of any
admin.login.enablePasskeyfalseEnable passkey sign-in. 'auto' to detect.
admin.login.enableSignUp'auto'Enable registration
admin.login.defaultSignUpRole'user'Default role for new users
admin.login.enableForgotPassword'auto'Enable forgot password
admin.login.resetPasswordUrlCustom password reset URL
admin.login.enablePassword'auto'Show the email/password field. false (or auto-detect) hides it for passwordless-only sign-in.
admin.login.enableMagicLink'auto'Enable magic-link ("email me a link") sign-in. Requires the Better Auth magicLink() plugin.
admin.login.enableEmailOtp'auto'Enable email-OTP ("email me a code") sign-in. Requires the Better Auth emailOTP() plugin.
admin.login.magicLinkCallbackURLWhere the emailed magic link returns. Defaults to afterLoginPath.
admin.login.enableSocialfalseShow social/OAuth provider buttons for providers configured in Better Auth's socialProviders. true shows all configured providers; an array is an allowlist of provider ids. There is no 'auto'.
admin.login.socialCallbackURLWhere a successful social sign-in returns. Defaults to the login page so the role gate runs; errors always return there.
admin.enableManagementUItrueEnable security management views
API Key Permissions configuration
OptionDefaultDescription
excludeCollectionsauth collectionsCollections to exclude from permissions UI
requiredRole'admin'Role required to manage API keys
ts// Permissions are auto-generated from collections
admin: {
  apiKey: {
    excludeCollections: ['internal-logs'],
    requiredRole: 'admin'
  }
}

betterAuthStrategy(options?)

Payload auth strategy for Better Auth session validation.

OptionTypeDescription
usersCollectionstringCollection slug for users (default: 'users')
idType'number' | 'text'Coerce IDs. Default 'number'. Use 'text' for MongoDB.

Session Helpers

getServerSession<TUser>(payload, headers)

Get the current session on the server with full type safety.

getServerUser<TUser>(payload, headers)

Shorthand for session.user.

createSessionHelpers<TUser>(options?)

Create typed session helpers. Define once, import everywhere — no generics at call sites.

ts// lib/auth.ts
import { createSessionHelpers } from '@delmaredigital/payload-better-auth'
import type { User } from '@/payload-types'

export const { getServerSession, getServerUser } = createSessionHelpers<User>()

// app/page.tsx — no generic needed
const session = await getServerSession(payload, headersList)

withBetterAuthDefaults(options)

Applies sensible defaults. Sets trustedOrigins: [baseURL] when not explicitly set.

API Key Configuration

API keys require the @better-auth/api-key package. Install it and configure directly in your Better Auth options:

bashpnpm add @better-auth/api-key
tsimport { apiKey } from '@better-auth/api-key'

export const betterAuthOptions = {
  plugins: [
    apiKey(),
  ],
}

The plugin's API key management UI and permission enforcement utilities work automatically once the apiKey plugin is configured.

Customization

Role-Based Access Control

The login page checks for the admin role by default. Configure via admin.login.requiredRole.

tsadmin: {
  login: {
    requiredRole: 'admin',                          // Single role
    requiredRole: ['admin', 'editor', 'moderator'], // Any of these
    requiredRole: null,                              // Disable checking
  },
}

Disabling Auto-Injection

tscreateBetterAuthPlugin({
  createAuth,
  autoRegisterEndpoints: false,
  autoInjectAdminComponents: false,
})
Custom admin components
tsadmin: {
  loginViewComponent: '@/components/admin/CustomLogin',
  logoutButtonComponent: '@/components/admin/CustomLogout',
  disableBeforeLogin: true,
}
Manual API route (advanced)
ts// src/app/api/auth/[...all]/route.ts
import type { PayloadWithAuth } from '@delmaredigital/payload-better-auth'

export async function GET(request: NextRequest) {
  const payload = (await getPayload({ config })) as PayloadWithAuth
  return payload.betterAuth.handler(request)
}

export async function POST(request: NextRequest) {
  const payload = (await getPayload({ config })) as PayloadWithAuth
  return payload.betterAuth.handler(request)
}

Access Control Helpers

Pre-built access control functions for common authorization patterns.

tsimport {
  isAdmin, isAdminField, isAdminOrSelf,
  hasRole, requireAllRoles,
  isAuthenticated, isAuthenticatedField,
  canUpdateOwnFields,
} from '@delmaredigital/payload-better-auth'

export const Posts: CollectionConfig = {
  slug: 'posts',
  access: {
    read: isAuthenticated(),
    create: hasRole(['editor', 'admin']),
    update: hasRole(['editor', 'admin']),
    delete: requireAllRoles(['admin', 'content-manager']),
  },
  fields: [{
    name: 'internalNotes',
    type: 'textarea',
    access: { read: isAdminField() },
  }],
}
Self-access patterns
tsaccess: {
  read: isAdminOrSelf({ adminRoles: ['admin'] }),
  update: canUpdateOwnFields({
    allowedFields: ['name', 'image', 'password'],
    userSlug: 'users',
    requireCurrentPassword: true,
  }),
  delete: isAdmin({ adminRoles: ['admin'] }),
}
Utility functions
tsimport { normalizeRoles, hasAnyRole, hasAllRoles } from '@delmaredigital/payload-better-auth'

const roles = normalizeRoles(user.role)
hasAnyRole(user, ['admin', 'editor'])
hasAllRoles(user, ['admin', 'editor'])

API Key Permission Enforcement

Enforce API key permissions in your Payload access control using Better Auth's native permission system.

tsimport { requirePermission, requireAllPermissions, allowSessionOrPermission } from '@delmaredigital/payload-better-auth'

access: {
  read: requirePermission('posts', 'read'),
  create: requirePermission('posts', 'write'),
  update: requirePermission('posts', 'write'),
  delete: requirePermission('posts', 'write'),
}

// Allow both session auth and API keys
read: allowSessionOrPermission('posts', 'read')

Two permission levels per collection: read (view only) and write (create, update, delete). Write implies read.

Advanced: Custom permission checks
tsimport { requireAnyPermission, requireAllPermissions, requireApiKey } from '@delmaredigital/payload-better-auth'

// Require ANY of these permissions
read: requireAnyPermission([
  { resource: 'posts', action: 'read' },
  { resource: 'pages', action: 'read' },
])

// Require ALL permissions
delete: requireAllPermissions([
  { resource: 'posts', action: 'write' },
  { resource: 'admin', action: 'write' },
])

// Just verify key is valid (no specific permissions)
read: requireApiKey()

Plugin Compatibility

The adapter uses Better Auth's createAdapterFactory which is schema-aware — it automatically supports all Better Auth plugins.

PluginPackageNotes
OAuthbetter-authUses accounts collection
Magic Linkbetter-authUses verifications collection · login UI supported
Email OTPbetter-authUses verifications collection · login UI supported
Two-Factorbetter-authAuto-generates twoFactors
API Keysbetter-authAuto-generates apikeys
Organizationsbetter-authAuto-generates orgs, members, invitations
Passkey@better-auth/passkeyAuto-generates passkeys
Adding join fields for relationships

Payload uses join fields to establish queryable parent-to-child relationships. When a plugin creates a model with a foreign key, add a join field to the parent collection:

ts// API Keys join
{ name: 'apiKeys', type: 'join', collection: 'apikeys', on: 'user' }

// Two-Factor join
{ name: 'twoFactor', type: 'join', collection: 'twoFactors', on: 'user' }

// Memberships join
{ name: 'memberships', type: 'join', collection: 'members', on: 'user' }
Cascade delete (cleanup orphaned records)
tsuser: {
  deleteUser: {
    enabled: true,
    afterDelete: async (user) => {
      const collections = ['sessions', 'accounts', 'apikeys', 'passkeys', 'twoFactors']
      for (const col of collections) {
        try {
          await payload.delete({ collection: col, where: { user: { equals: user.id } } })
        } catch {} // Collection may not exist
      }
    },
  },
}

UI Components

User Registration

The LoginView automatically detects whether registration is available from your Better Auth configuration. If emailAndPassword.enabled: true (and not disableSignUp: true), the "Create account" link appears automatically.

No configuration needed for most cases — it just works.

Password Reset

The "Forgot password?" link appears automatically when the reset endpoint is available.

Standalone components
tsimport { ForgotPasswordView, ResetPasswordView } from '@delmaredigital/payload-better-auth/components/auth'

<ForgotPasswordView
  logo={<MyLogo />}
  title="Forgot Password"
  loginPath="/admin/login"
/>

<ResetPasswordView
  logo={<MyLogo />}
  title="Reset Password"
  afterResetPath="/admin/login"
  minPasswordLength={8}
/>

Two-Factor Authentication

The LoginView handles 2FA inline automatically. When a user with 2FA enabled signs in, the form transitions to a TOTP code input.

Standalone components for custom flows
tsimport { TwoFactorSetupView, TwoFactorVerifyView } from '@delmaredigital/payload-better-auth/components/twoFactor'

<TwoFactorSetupView logo={<MyLogo />} afterSetupPath="/admin" />
<TwoFactorVerifyView logo={<MyLogo />} afterVerifyPath="/admin" />
Handling 2FA in custom login forms

Important: Always check result.data?.twoFactorRedirect after signIn.email(). Without this, users with 2FA enabled appear to log in but won't actually be authenticated.

tsconst result = await signIn.email({ email, password })

if (result.data?.twoFactorRedirect) {
  // Show TOTP verification form
  setTwoFactorRequired(true)
  return
}

// Then verify with:
await twoFactor.verifyTotp({ code: totpCode })

Passkeys

ts// Enable in LoginView
admin: { login: { enablePasskey: true } }

// Or use standalone button
import { PasskeySignInButton } from '@delmaredigital/payload-better-auth/components/passkey'

<PasskeySignInButton
  onSuccess={(user) => router.push('/dashboard')}
  onError={(error) => setError(error)}
/>
Passkey registration & management
ts// Registration button
import { PasskeyRegisterButton } from '@delmaredigital/payload-better-auth/components/passkey'
<PasskeyRegisterButton passkeyName="My MacBook" onSuccess={refetch} />

// Full management UI
import { PasskeysManagementClient } from '@delmaredigital/payload-better-auth/components/passkey'
<PasskeysManagementClient title="Manage Passkeys" />

// Or use the auth client directly (requires passkeyClient() in your client plugins)
await authClient.passkey.addPasskey({ name: 'My Device' })
await authClient.passkey.listUserPasskeys()
await authClient.passkey.deletePasskey({ id: passkeyId })

Passwordless Sign-In (Magic Link & Email OTP)

The LoginView auto-detects the Better Auth magicLink() and emailOTP() plugins and surfaces "Email me a link" / "Email me a code" options on the admin login. If your Better Auth config sets emailAndPassword.enabled: false, the password field is hidden automatically and a passwordless method becomes the primary action.

ts// Each option defaults to 'auto' (show iff the matching plugin is installed).
// Force on/off or hide the password field for passwordless-only sign-in:
admin: {
  login: {
    enableMagicLink: 'auto',   // requires magicLink() plugin
    enableEmailOtp: 'auto',    // requires emailOTP() plugin
    enablePassword: false,     // hide password field (passwordless-only)
    magicLinkCallbackURL: '/admin',
    enableSocial: ['google', 'github'], // opt-in; requires socialProviders configured
  },
}

Server-side wiring stays yours: configure the magicLink() / emailOTP() plugins and their email senders in your Better Auth instance, and the UI lights up automatically. Added in v0.7.5.

Social Sign-In

Set admin.login.enableSocial to true (all configured providers) or an array of provider ids to surface "Continue with …" buttons on the admin login page. Configure socialProviders in your Better Auth instance as usual — the UI reflects exactly what is configured there.

Account creation warning: A social button on the public admin login lets anyone with that provider account create a (non-admin) user row — the role gate blocks admin access, but the row (and any creation hooks) is still created. Set Better Auth's disableImplicitSignUp (per-provider or global) if you don't want open sign-up. This is why enableSocial defaults to false.

Security Management UI

Auto-injected management views based on enabled plugins:

ViewPathPlugin Required
Two-Factor Auth/admin/security/two-factortwoFactor()
API Keys/admin/security/api-keysapiKey()
Passkeys/admin/security/passkeyspasskey()

Recipes

Auto-create organization on user signup

Use a lazy auth instance singleton to call auth.api.createOrganization() from database hooks (so organizationHooks fire properly).

1. Create an auth instance singleton

ts// src/lib/auth/instance.ts
let authInstance: AuthInstance | null = null

export function setAuthInstance(auth: AuthInstance) { authInstance = auth }
export function getAuthInstance() {
  if (!authInstance) throw new Error('Auth not initialized')
  return authInstance
}

2. Store after creation

tscreateBetterAuthPlugin({
  createAuth: (payload) => {
    const auth = betterAuth({ ...betterAuthOptions, database: payloadAdapter({ payloadClient: payload }) })
    setAuthInstance(auth)
    return auth
  },
})

3. Use in database hooks

tsdatabaseHooks: {
  user: {
    update: {
      after: async (user, ctx) => {
        if (!user.emailVerified) return
        const existing = await ctx?.context?.adapter?.findOne({
          model: 'member', where: [{ field: 'userId', value: user.id }],
        })
        if (existing) return

        const auth = getAuthInstance()
        await auth.api.createOrganization({
          body: { name: `${user.name}'s Workspace`, slug: generateSlug(user.name), userId: user.id },
        })
      },
    },
  },
}

Always use auth.api.createOrganization() instead of raw adapter calls. The adapter bypasses organizationHooks entirely.

Types

tsimport type {
  PayloadWithAuth,
  PayloadRequestWithBetterAuth,
  BetterAuthReturn,
  CollectionHookWithBetterAuth,
  EndpointWithBetterAuth,
} from '@delmaredigital/payload-better-auth'

// Generated schema types
import type {
  User, BetterAuthSession, Account,
  Apikey, Passkey, Organization, Member, TwoFactor,
} from '@delmaredigital/payload-better-auth'

Regenerate types after adding plugins:

pnpm generate:types

MIT License — @delmaredigital/payload-better-auth