Skip to Content
Delmare Digital

Quick start

Wire Better Auth into Payload in five steps, from the auth config to reading the session in a server component.

This walks through a Postgres setup with email and password sign-in. For MongoDB, follow the same steps with the changes in MongoDB.

Create your auth configuration

Keep the Better Auth options in their own module. Both the collection generator and the Better Auth instance read them.

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 is assigned by the first-user-admin hook; configure the
      // default self-sign-up role via `firstUserAdmin: { defaultRole }`.
      role: { type: 'string', defaultValue: 'user', input: false },
    },
  },
  session: {
    expiresIn: 60 * 60 * 24 * 30, // 30 days
  },
  emailAndPassword: { enabled: true },
}

Create your users collection

The users collection turns off Payload's local strategy and authenticates with betterAuthStrategy().

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 (for example twoFactorEnabled for two-factor) are added to your users collection automatically by betterAuthCollections().

Every additionalFields entry needs a matching field here. Scalars map to the obvious Payload type. An array-typed one (string[] / number[]) needs a field that stores an array — json, or select with hasMany: true — because the adapter writes real arrays, not serialized ones:

// Better Auth: roles: { type: 'string[]' }
{ name: 'roles', type: 'json' }

To read roles from a property other than role, see Naming the role field.

Configure Payload

Register the collection generator and the Better Auth plugin. skipCollections: ['user'] tells the generator that you defined the users collection yourself.

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'
import { betterAuthOptions } from './lib/auth/config'
import { Users } from './collections/Users'
import { getBaseUrl } from './lib/auth/getBaseUrl'

const baseUrl = getBaseUrl()

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET!,
  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 },
  }),
})

After creating these files, regenerate Payload's types and import map:

pnpm exec payload generate:types
pnpm exec payload generate:importmap

getBaseUrl() is the helper from Installation. generateId: 'serial' matches Payload's default serial IDs on Postgres.

Create the client

Create the browser client for the email/password setup above:

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

import { createAuthClient } from '@delmaredigital/payload-better-auth/client'

export const authClient = createAuthClient()

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

Other ways to build the client, and why the shortcuts are typed loosely, are in Client.

Read the session on the server

src/app/(frontend)/dashboard/page.tsx
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import config from '@payload-config'
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>
}

getServerSession() reads the session without refreshing it. How sessions slide, and how to let a Local API call refresh one, is in Sessions.

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

Using a non-default API route?

If your Payload config sets routes.api to something other than /api, Better Auth needs a matching basePath. See Custom API route.

Edit this page on GitHub

On this page