React
|stacknotice.com
16 min left|
0%
|3,200 words
React

Better Auth + Next.js: The Complete Production Guide (2026)

Complete Better Auth setup for Next.js 15: email/password, Google OAuth, organizations, email verification with Resend, session management, middleware. Full production patterns.

C
Carlos Oliva
Software Developer
September 23, 202616 min read
Share:
Better Auth + Next.js: The Complete Production Guide (2026)
ℹWhat this covers

Full Better Auth setup: Drizzle schema, email/password, Google OAuth, organizations plugin, email verification with Resend, password reset, session in Server Components and Client Components, middleware, and the comparison with Clerk and Auth.js that helps you choose.

Better Auth has become the serious alternative to Clerk for teams that want full control without the per-user cost. It's TypeScript-first (types inferred from config, zero manual augmentation), ships as a Node.js library that runs in your codebase, and stores sessions in your own database.

The DX has improved significantly in 2026. The @better-auth/cli generates your schema automatically. The better-auth-ui package provides pre-built components. And the plugin ecosystem — organizations, 2FA, passkeys, admin panel — covers most B2B SaaS needs.

This guide builds a complete production setup from scratch.

Why Choose Better Auth in 2026

The auth options for Next.js are narrower than they look:

Clerk — best DX, hosted, built-in org management. $25/month at 10k MAU, ~$825/month at 50k MAU. You don't own the data.

Auth.js (NextAuth v5) — free, open source. Session typing is manual, config is complex, the v4 → v5 migration is painful. Best if you're maintaining an existing NextAuth codebase.

Better Auth — self-hosted, TypeScript-first, free at any scale. You own the data. The tradeoff: you're responsible for your own database, email sending, and session infrastructure.

The inflection point where Better Auth wins: when user data sovereignty matters, when you're building for the EU with GDPR concerns, or when you're at scale where Clerk's cost is meaningful.

✦Clerk vs Better Auth — the real decision

If organizations are a core product feature and you have fewer than 10k MAU, Clerk's free tier is hard to beat. Once you're past that or need data residency control, Better Auth is the answer. Full comparison: Clerk vs Better Auth in Next.js

Installation

npm install better-auth
npm install -D @better-auth/cli

Environment variables:

# .env.local
BETTER_AUTH_SECRET=           # min 32 chars — run: openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=postgresql://...
 
# OAuth providers
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
 
# Email (Resend for email verification / password reset)
RESEND_API_KEY=

Auth Configuration

lib/auth.ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { organization } from 'better-auth/plugins'
import { db } from '@/lib/db'
import * as schema from '@/lib/schema'
 
export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema: {
      user:         schema.user,
      session:      schema.session,
      account:      schema.account,
      verification: schema.verification,
    },
  }),
 
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: 'Reset your password',
        html: `<a href="${url}">Reset password</a>`,
      })
    },
  },
 
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: 'Verify your email',
        html: `<p>Hi ${user.name},</p><p><a href="${url}">Verify your email</a></p>`,
      })
    },
  },
 
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
 
  plugins: [
    organization(), // B2B multi-tenancy — teams, invites, roles
  ],
 
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24,     // refresh token if older than 1 day
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // cache session cookie for 5 minutes
    },
  },
})
 
export type Session = typeof auth.$Infer.Session
export type User    = typeof auth.$Infer.Session.user

The $Infer types give you full TypeScript inference with no manual augmentation — add a plugin and the types update automatically.

Email Sending with Resend

Create a shared sendEmail helper used by the auth config above:

lib/email.ts
import { Resend } from 'resend'
 
const resend = new Resend(process.env.RESEND_API_KEY!)
 
export async function sendEmail({
  to,
  subject,
  html,
}: {
  to: string
  subject: string
  html: string
}) {
  const { error } = await resend.emails.send({
    from: 'StackNotice <noreply@stacknotice.com>',
    to,
    subject,
    html,
  })
 
  if (error) {
    console.error('[email] failed to send:', error)
    throw new Error('Failed to send email')
  }
}

Database Schema

Generate the schema automatically from your auth config:

npx @better-auth/cli generate

Or define it manually with Drizzle (gives you more control for adding custom columns):

lib/schema.ts
import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
 
export const user = pgTable('user', {
  id:            text('id').primaryKey(),
  name:          text('name').notNull(),
  email:         text('email').notNull().unique(),
  emailVerified: boolean('email_verified').notNull().default(false),
  image:         text('image'),
  role:          text('role').notNull().default('user'), // custom field
  createdAt:     timestamp('created_at').notNull(),
  updatedAt:     timestamp('updated_at').notNull(),
})
 
export const session = pgTable('session', {
  id:          text('id').primaryKey(),
  expiresAt:   timestamp('expires_at').notNull(),
  token:       text('token').notNull().unique(),
  ipAddress:   text('ip_address'),
  userAgent:   text('user_agent'),
  userId:      text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  createdAt:   timestamp('created_at').notNull(),
  updatedAt:   timestamp('updated_at').notNull(),
})
 
export const account = pgTable('account', {
  id:           text('id').primaryKey(),
  accountId:    text('account_id').notNull(),
  providerId:   text('provider_id').notNull(),
  userId:       text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  accessToken:  text('access_token'),
  refreshToken: text('refresh_token'),
  idToken:      text('id_token'),
  expiresAt:    timestamp('expires_at'),
  createdAt:    timestamp('created_at').notNull(),
  updatedAt:    timestamp('updated_at').notNull(),
})
 
export const verification = pgTable('verification', {
  id:         text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value:      text('value').notNull(),
  expiresAt:  timestamp('expires_at').notNull(),
  createdAt:  timestamp('created_at'),
  updatedAt:  timestamp('updated_at'),
})

If you add custom fields to the user table (like role above), tell Better Auth to use them:

lib/auth.ts
export const auth = betterAuth({
  // ...
  user: {
    additionalFields: {
      role: {
        type: 'string',
        required: false,
        defaultValue: 'user',
        input: false, // not user-provided on sign up
      },
    },
  },
})

Run migrations after any schema change:

npx drizzle-kit generate
npx drizzle-kit migrate

API Route Handler

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

This one file handles every auth endpoint: sign-in, sign-up, OAuth callbacks, sign-out, email verification, password reset, session management, and all plugin endpoints.

Auth Client

lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'
import { organizationClient } from 'better-auth/client/plugins'
 
export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
  plugins: [
    organizationClient(), // mirror the server-side plugin on the client
  ],
})
 
export const {
  signIn,
  signUp,
  signOut,
  useSession,
  organization,
} = authClient
⚠Plugin parity

Every plugin you add to the server auth config needs its client-side counterpart in createAuthClient. They're separate packages — better-auth/plugins vs better-auth/client/plugins.

Sign Up and Sign In

Using React 19's useActionState instead of manual useState + loading state:

Sign Up

app/(auth)/sign-up/page.tsx
'use client'
 
import { useActionState } from 'react'
import { useRouter } from 'next/navigation'
import { signUp } from '@/lib/auth-client'
 
type State = { error: string | null }
 
async function signUpAction(prev: State, formData: FormData): Promise<State> {
  const { error } = await signUp.email({
    email:       formData.get('email') as string,
    password:    formData.get('password') as string,
    name:        formData.get('name') as string,
    callbackURL: '/dashboard',
  })
 
  return { error: error?.message ?? null }
}
 
export default function SignUpPage() {
  const [state, formAction, isPending] = useActionState(signUpAction, { error: null })
 
  return (
    <form action={formAction} className="space-y-4 max-w-sm mx-auto">
      <h1 className="text-2xl font-bold">Create account</h1>
 
      <input name="name"     type="text"     placeholder="Full name" required />
      <input name="email"    type="email"    placeholder="Email"     required />
      <input name="password" type="password" placeholder="Password"  required minLength={8} />
 
      {state.error && (
        <p className="text-sm text-red-500">{state.error}</p>
      )}
 
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating account...' : 'Create account'}
      </button>
    </form>
  )
}

Sign In

app/(auth)/sign-in/page.tsx
'use client'
 
import { useActionState } from 'react'
import { signIn } from '@/lib/auth-client'
 
type State = { error: string | null }
 
async function signInAction(prev: State, formData: FormData): Promise<State> {
  const { error } = await signIn.email({
    email:       formData.get('email') as string,
    password:    formData.get('password') as string,
    callbackURL: '/dashboard',
    rememberMe:  true,
  })
 
  return { error: error?.message ?? null }
}
 
export default function SignInPage() {
  const [state, formAction, isPending] = useActionState(signInAction, { error: null })
 
  async function handleGoogleSignIn() {
    await signIn.social({ provider: 'google', callbackURL: '/dashboard' })
  }
 
  return (
    <div className="space-y-4 max-w-sm mx-auto">
      <h1 className="text-2xl font-bold">Sign in</h1>
 
      <button
        onClick={handleGoogleSignIn}
        type="button"
        className="w-full border rounded-lg px-4 py-2"
      >
        Continue with Google
      </button>
 
      <div className="text-center text-sm text-gray-400">or</div>
 
      <form action={formAction} className="space-y-4">
        <input name="email"    type="email"    placeholder="Email"    required />
        <input name="password" type="password" placeholder="Password" required />
 
        {state.error && (
          <p className="text-sm text-red-500">{state.error}</p>
        )}
 
        <button type="submit" disabled={isPending}>
          {isPending ? 'Signing in...' : 'Sign in'}
        </button>
      </form>
 
      <p className="text-sm text-center">
        <a href="/reset-password" className="text-blue-500">Forgot password?</a>
      </p>
    </div>
  )
}

Reading the Session

In Server Components

app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
 
export default async function DashboardPage() {
  const session = await auth.api.getSession({
    headers: await headers(),   // ← async in Next.js 15
  })
 
  if (!session) redirect('/sign-in')
 
  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Email: {session.user.email}</p>
      <p>Role: {session.user.role}</p>  {/* custom field is fully typed */}
    </div>
  )
}

auth.api.getSession() reads the session cookie and validates it against the database. It's a real DB query — don't call it in multiple places per request. If you need the session in several Server Components, pass it as a prop from the page.

In Client Components

components/UserNav.tsx
'use client'
 
import { useSession, signOut } from '@/lib/auth-client'
import { useRouter } from 'next/navigation'
 
export function UserNav() {
  const { data: session, isPending } = useSession()
  const router = useRouter()
 
  if (isPending) return <div className="h-8 w-8 rounded-full bg-gray-800 animate-pulse" />
  if (!session) return <a href="/sign-in">Sign in</a>
 
  return (
    <div className="flex items-center gap-3">
      <span className="text-sm">{session.user.name}</span>
      <button
        onClick={async () => {
          await signOut({ fetchOptions: { onSuccess: () => router.push('/') } })
        }}
        className="text-sm text-gray-400"
      >
        Sign out
      </button>
    </div>
  )
}

Password Reset

The flow: user requests reset → Better Auth generates a token → your sendResetPassword callback sends the email → user clicks link → Better Auth validates the token.

The UI:

app/(auth)/reset-password/page.tsx
'use client'
 
import { useActionState } from 'react'
import { authClient } from '@/lib/auth-client'
 
async function requestResetAction(
  prev: { done: boolean; error: string | null },
  formData: FormData
) {
  const { error } = await authClient.requestPasswordReset({
    email:       formData.get('email') as string,
    redirectTo:  '/reset-password/confirm',
  })
  return { done: !error, error: error?.message ?? null }
}
 
export default function ResetPasswordPage() {
  const [state, formAction, isPending] = useActionState(requestResetAction, {
    done: false,
    error: null,
  })
 
  if (state.done) {
    return <p>Check your email for a reset link.</p>
  }
 
  return (
    <form action={formAction} className="space-y-4">
      <h1 className="text-xl font-bold">Reset password</h1>
      <input name="email" type="email" placeholder="Your email" required />
      {state.error && <p className="text-red-500 text-sm">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send reset link'}
      </button>
    </form>
  )
}

Organizations Plugin

The organization plugin adds multi-tenancy: teams with members, invitations, roles. Essential for B2B SaaS.

Enable in auth config (already shown above). Then use it:

app/dashboard/page.tsx
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
 
export default async function DashboardPage() {
  const session = await auth.api.getSession({ headers: await headers() })
  if (!session) redirect('/sign-in')
 
  // Get the user's active organization
  const orgs = await auth.api.listOrganizations({ headers: await headers() })
 
  return <Dashboard user={session.user} organizations={orgs} />
}

Client-side org operations:

import { organization } from '@/lib/auth-client'
 
// Create an organization
await organization.create({
  name: 'Acme Inc',
  slug: 'acme',
})
 
// Invite a member
await organization.inviteMember({
  email:          'colleague@example.com',
  role:           'member',
  organizationId: orgId,
})
 
// Switch active organization
await organization.setActive({ organizationId: orgId })

The plugin adds these tables to your schema: organization, member, invitation. Generate them with npx @better-auth/cli generate after enabling the plugin.

If your SaaS is B2B and organizations are a core concept, scope every query to the active organization:

export async function getActiveOrg(request?: Request) {
  const session = await auth.api.getSession({
    headers: request ? request.headers : await headers(),
  })
  if (!session) return null
 
  const orgs = await auth.api.listOrganizations({
    headers: request ? request.headers : await headers(),
  })
  return orgs[0] ?? null
}

better-auth-ui — Pre-Built Components

better-auth-ui gives you Clerk-style UI components for Better Auth. Install:

npm install better-auth-ui

Then use the pre-built components:

app/(auth)/sign-in/page.tsx
import { AuthCard } from 'better-auth-ui'
import { auth } from '@/lib/auth'
 
export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <AuthCard
        authClient={authClient}
        view="signIn"
        redirectTo="/dashboard"
      />
    </div>
  )
}

AuthCard renders sign-in, sign-up, reset password views with your existing Tailwind/shadcn styles. If you want full control over the UI, build the forms manually as shown above. If you want to ship auth UI quickly, better-auth-ui is the fastest path.

Middleware

For middleware, use a fast cookie-presence check. Better Auth session tokens are opaque — you can't verify them in the Edge Runtime without a DB call, so don't try:

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
const SESSION_COOKIE = 'better-auth.session_token'
 
const PROTECTED_PREFIXES = ['/dashboard', '/settings', '/onboarding']
const AUTH_PATHS          = ['/sign-in', '/sign-up']
 
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const sessionToken = request.cookies.get(SESSION_COOKIE)?.value
 
  const isProtected = PROTECTED_PREFIXES.some((p) => pathname.startsWith(p))
  const isAuthPath  = AUTH_PATHS.some((p) => pathname.startsWith(p))
 
  if (isProtected && !sessionToken) {
    const url = new URL('/sign-in', request.url)
    url.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(url)
  }
 
  if (isAuthPath && sessionToken) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth).*)'],
}

Users with no session cookie redirect instantly at the edge. Users with a token continue, and your Server Components call auth.api.getSession() for the actual DB validation. This means a user with an expired or revoked token reaches the page — handle that case in the Server Component:

const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect('/sign-in?error=session_expired')

Session Management

Programmatic session control:

// List all active sessions for the current user
const sessions = await auth.api.listSessions({ headers: await headers() })
 
// Revoke a specific session
await auth.api.revokeSession({ token: sessionToken })
 
// Revoke all sessions except current (force sign out everywhere else)
await auth.api.revokeOtherSessions({ headers: await headers() })

These are useful for a "Sign out all devices" feature in your account settings.

Better Auth vs Clerk vs Auth.js

Better AuthClerkAuth.js v5
CostFree (self-hosted)Free → $25/mo at 10k MAUFree
Data ownership✅ Your DB❌ Clerk's servers✅ Your DB
Type safetyInferred — no manual augmentationGoodManual augmentation required
Setup time~45 min~10 min~60 min
Built-in UIbetter-auth-ui (separate package)✅❌
OrganizationsPluginBuilt-inManually implemented
Email verificationBuilt-in (bring your email sender)Built-inManual
Password resetBuilt-inBuilt-inManual
2FA / PasskeysPluginsBuilt-inLimited
SAML/SSOEnterprise pluginEnterprise plan❌
GDPR / data residency✅ Full controlDepends on region settings✅ Full control

The decision:

  • Better Auth — full control, no per-user cost, TypeScript-first, GDPR by default
  • Clerk — fastest DX, managed infrastructure, pay for scale
  • Auth.js — existing NextAuth codebase, maximum community resources

Common Mistakes

Wrong session cookie name in middleware. The default is better-auth.session_token. If you customize the cookie name in your auth config, update the middleware constant.

Not awaiting headers() in Next.js 15. All calls to auth.api.getSession({ headers: await headers() }) must await headers(). Skipping the await causes a subtle runtime error.

Calling auth.api.getSession() multiple times per request. It hits the database each time. Read it once at the page level and pass the session down as a prop.

Forgetting to add the API route to the middleware exclusion. If api/auth/* is matched by your middleware, every auth request loops through your auth guard. Add it to the exclusion pattern.

Schema out of sync after adding plugins. After enabling a new plugin (organizations, 2FA), run npx @better-auth/cli generate and then npx drizzle-kit generate && npx drizzle-kit migrate. The plugin adds new tables that don't exist until you run the migration.

requireEmailVerification: true without an email provider. Users will sign up and get stuck — the verification email never arrives. Set up Resend first, then enable email verification.


Complete source for this setup with all files: stacknotice.com/blog/better-auth-nextjs-complete-guide-2026

Related: Clerk vs Better Auth — full comparison · Drizzle ORM complete guide · Next.js Middleware guide · Resend + React Email in Next.js

#nextjs#typescript#authentication#webdev#react
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.