Published on

Integrating Better Auth with Next.js: Step-by-Step Guide

A complete walkthrough for adding Better Auth (email/password & Google sign-in) to your Next.js app with modern form handling.

Integrating Better Auth with Next.js

Better Auth is a modern, open-source authentication framework for TypeScript, designed to work seamlessly with Next.js. This guide will walk you through integrating Better Auth into your Next.js project with modern form handling using Zod, shadcn/ui, react-hook-form, and server actions.


1. Scaffold Your Next.js Project

If you don't have a Next.js project yet, create one:

pnpm dlx create-next-app@15 my-better-auth-app
cd my-better-auth-app

When you run the command above, you'll be prompted with a few setup questions in your terminal, such as:

 Would you like to use TypeScript? No / Yes
 Would you like to use ESLint? No / Yes
 Would you like to use Tailwind CSS? No / Yes
 Would you like to use `src/` directory? No / Yes
 Would you like to use App Router? (recommended) … No / Yes
 Would you like to use Turbopack for `next dev`? … No / Yes
 Would you like to customize the import alias (`@/*` by default)? … No / Yes

You can press Enter to accept the defaults, or type "n" for "No". To skip the prompts and use all defaults, add the -y flag:

pnpm dlx create-next-app@latest my-better-auth-app -y

2. Install Dependencies

Install Better Auth, Prisma, and the modern form handling stack:

pnpm add better-auth @prisma/client zod react-hook-form @hookform/resolvers
npm install --save-dev prisma @better-auth/cli

3. Set Up shadcn/ui

Initialize shadcn/ui for beautiful, accessible components:

pnpm dlx shadcn@latest init

Install the form components:

pnpm dlx shadcn@latest add form input button card label

4. Set Up Environment Variables

Create a .env file in your project root:

# Better Auth
BETTER_AUTH_SECRET=replace-with-64-random-hex
BETTER_AUTH_URL=http://localhost:3000
 
# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
 
# Database
DATABASE_URL=postgresql://<user>:<password>@localhost:5432/<database>

Generate a random secret for BETTER_AUTH_SECRET:

openssl rand -hex 32

Set up your Google OAuth credentials in the Google Cloud Console.


5. Initialize Prisma

pnpm dlx prisma init --datasource-provider postgresql

6. Connect Better Auth to Your Database

Create lib/auth.ts in your project:

import { PrismaClient } from '@prisma/client';
 
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
 
const prisma = new PrismaClient();
 
export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: 'postgresql'
  }),
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!
    }
  },
  plugins: [] // Add plugins as needed
});

7. Generate Prisma Models for Better Auth

pnpm dlx @better-auth/cli generate --y

This will update your prisma/schema.prisma with the required models.

Run migrations:

pnpm dlx prisma migrate dev --name auth

8. Expose the Auth Handler to Next.js

Create app/api/auth/[...all]/route.ts:

import { auth } from '@/lib/auth';
 
import { toNextJsHandler } from 'better-auth/next-js';
 
export const { GET, POST } = toNextJsHandler(auth.handler);

9. Create Form Validation Schemas

Create lib/validations/auth.ts:

import { z } from 'zod';
 
export const signInSchema = z.object({
  email: z.string().email('Please enter a valid email address'),
  password: z.string().min(1, 'Password is required')
});
 
export const signUpSchema = z
  .object({
    name: z.string().min(2, 'Name must be at least 2 characters'),
    email: z.string().email('Please enter a valid email address'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    confirmPassword: z.string()
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ['confirmPassword']
  });
 
export type SignInInput = z.infer<typeof signInSchema>;
export type SignUpInput = z.infer<typeof signUpSchema>;

10. Create Server Actions

Create lib/actions/auth.ts:

'use server';
 
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
 
import { auth } from '@/lib/auth';
import { signInSchema, signUpSchema } from '@/lib/validations/auth';
 
export async function signInAction(formData: FormData) {
  const validatedFields = signInSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password')
  });
 
  if (!validatedFields.success) {
    return {
      error: 'Invalid fields',
      details: validatedFields.error.flatten().fieldErrors
    };
  }
 
  const { email, password } = validatedFields.data;
 
  try {
    await auth.api.signIn.email({
      email,
      password
    });
  } catch (error) {
    return {
      error: 'Invalid credentials'
    };
  }
 
  revalidatePath('/');
  redirect('/dashboard');
}
 
export async function signUpAction(formData: FormData) {
  const validatedFields = signUpSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    password: formData.get('password'),
    confirmPassword: formData.get('confirmPassword')
  });
 
  if (!validatedFields.success) {
    return {
      error: 'Invalid fields',
      details: validatedFields.error.flatten().fieldErrors
    };
  }
 
  const { name, email, password } = validatedFields.data;
 
  try {
    await auth.api.signUp.email({
      name,
      email,
      password
    });
  } catch (error) {
    return {
      error: 'Failed to create account'
    };
  }
 
  revalidatePath('/');
  redirect('/dashboard');
}
 
export async function signOutAction() {
  await auth.api.signOut();
  revalidatePath('/');
  redirect('/');
}

11. Create Modern Sign-In Form

Create app/(auth)/sign-in/page.tsx:

import { Metadata } from 'next';
import Link from 'next/link';
 
import { SignInForm } from '@/components/auth/sign-in-form';
 
export const metadata: Metadata = {
  title: 'Sign In',
  description: 'Sign in to your account'
};
 
export default function SignInPage() {
  return (
    <div className='container relative grid min-h-screen flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0'>
      <div className='bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r'>
        <div className='absolute inset-0 bg-zinc-900' />
        <div className='relative z-20 flex items-center text-lg font-medium'>
          <Link href='/'>Your App</Link>
        </div>
        <div className='relative z-20 mt-auto'>
          <blockquote className='space-y-2'>
            <p className='text-lg'>
              &ldquo;This library has saved me countless hours of work and
              helped me deliver stunning designs to my clients faster than ever
              before.&rdquo;
            </p>
            <footer className='text-sm'>Sofia Davis</footer>
          </blockquote>
        </div>
      </div>
      <div className='lg:p-8'>
        <div className='mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]'>
          <div className='flex flex-col space-y-2 text-center'>
            <h1 className='text-2xl font-semibold tracking-tight'>
              Welcome back
            </h1>
            <p className='text-muted-foreground text-sm'>
              Enter your credentials to sign in to your account
            </p>
          </div>
          <SignInForm />
          <p className='text-muted-foreground px-8 text-center text-sm'>
            <Link
              href='/sign-up'
              className='hover:text-brand underline underline-offset-4'>
              Don&apos;t have an account? Sign up
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
}

Create components/auth/sign-in-form.tsx:

'use client';
 
import { useState } from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { signInAction } from '@/lib/actions/auth';
import { type SignInInput, signInSchema } from '@/lib/validations/auth';
import { zodResolver } from '@hookform/resolvers/zod';
 
import { useForm } from 'react-hook-form';
 
export function SignInForm() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  const form = useForm<SignInInput>({
    resolver: zodResolver(signInSchema),
    defaultValues: {
      email: '',
      password: ''
    }
  });
 
  async function onSubmit(data: SignInInput) {
    setIsLoading(true);
    setError(null);
 
    const formData = new FormData();
    formData.append('email', data.email);
    formData.append('password', data.password);
 
    const result = await signInAction(formData);
 
    if (result?.error) {
      setError(result.error);
      setIsLoading(false);
    }
  }
 
  return (
    <div className='grid gap-6'>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
          <FormField
            control={form.control}
            name='email'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input
                    placeholder='name@example.com'
                    type='email'
                    disabled={isLoading}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='password'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input
                    placeholder='Enter your password'
                    type='password'
                    disabled={isLoading}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          {error && (
            <div className='text-sm text-red-600 dark:text-red-400'>
              {error}
            </div>
          )}
          <Button type='submit' className='w-full' disabled={isLoading}>
            {isLoading ? 'Signing in...' : 'Sign in'}
          </Button>
        </form>
      </Form>
      <div className='relative'>
        <div className='absolute inset-0 flex items-center'>
          <span className='w-full border-t' />
        </div>
        <div className='relative flex justify-center text-xs uppercase'>
          <span className='bg-background text-muted-foreground px-2'>
            Or continue with
          </span>
        </div>
      </div>
      <Button variant='outline' type='button' disabled={isLoading}>
        <svg
          className='mr-2 h-4 w-4'
          aria-hidden='true'
          focusable='false'
          data-prefix='fab'
          data-icon='github'
          role='img'
          xmlns='http://www.w3.org/2000/svg'
          viewBox='0 0 496 512'>
          <path
            fill='currentColor'
            d='M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h240z'></path>
        </svg>
        Google
      </Button>
    </div>
  );
}

12. Create Modern Sign-Up Form

Create app/(auth)/sign-up/page.tsx:

import { Metadata } from 'next';
import Link from 'next/link';
 
import { SignUpForm } from '@/components/auth/sign-up-form';
 
export const metadata: Metadata = {
  title: 'Sign Up',
  description: 'Create a new account'
};
 
export default function SignUpPage() {
  return (
    <div className='container relative grid min-h-screen flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0'>
      <div className='bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r'>
        <div className='absolute inset-0 bg-zinc-900' />
        <div className='relative z-20 flex items-center text-lg font-medium'>
          <Link href='/'>Your App</Link>
        </div>
        <div className='relative z-20 mt-auto'>
          <blockquote className='space-y-2'>
            <p className='text-lg'>
              &ldquo;This library has saved me countless hours of work and
              helped me deliver stunning designs to my clients faster than ever
              before.&rdquo;
            </p>
            <footer className='text-sm'>Sofia Davis</footer>
          </blockquote>
        </div>
      </div>
      <div className='lg:p-8'>
        <div className='mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]'>
          <div className='flex flex-col space-y-2 text-center'>
            <h1 className='text-2xl font-semibold tracking-tight'>
              Create an account
            </h1>
            <p className='text-muted-foreground text-sm'>
              Enter your information to create your account
            </p>
          </div>
          <SignUpForm />
          <p className='text-muted-foreground px-8 text-center text-sm'>
            <Link
              href='/sign-in'
              className='hover:text-brand underline underline-offset-4'>
              Already have an account? Sign in
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
}

Create components/auth/sign-up-form.tsx:

'use client';
 
import { useState } from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { signUpAction } from '@/lib/actions/auth';
import { type SignUpInput, signUpSchema } from '@/lib/validations/auth';
import { zodResolver } from '@hookform/resolvers/zod';
 
import { useForm } from 'react-hook-form';
 
export function SignUpForm() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  const form = useForm<SignUpInput>({
    resolver: zodResolver(signUpSchema),
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: ''
    }
  });
 
  async function onSubmit(data: SignUpInput) {
    setIsLoading(true);
    setError(null);
 
    const formData = new FormData();
    formData.append('name', data.name);
    formData.append('email', data.email);
    formData.append('password', data.password);
    formData.append('confirmPassword', data.confirmPassword);
 
    const result = await signUpAction(formData);
 
    if (result?.error) {
      setError(result.error);
      setIsLoading(false);
    }
  }
 
  return (
    <div className='grid gap-6'>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
          <FormField
            control={form.control}
            name='name'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Full Name</FormLabel>
                <FormControl>
                  <Input
                    placeholder='John Doe'
                    disabled={isLoading}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='email'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input
                    placeholder='name@example.com'
                    type='email'
                    disabled={isLoading}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='password'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input
                    placeholder='Create a password'
                    type='password'
                    disabled={isLoading}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='confirmPassword'
            render={({ field }) => (
              <FormItem>
                <FormLabel>Confirm Password</FormLabel>
                <FormControl>
                  <Input
                    placeholder='Confirm your password'
                    type='password'
                    disabled={isLoading}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          {error && (
            <div className='text-sm text-red-600 dark:text-red-400'>
              {error}
            </div>
          )}
          <Button type='submit' className='w-full' disabled={isLoading}>
            {isLoading ? 'Creating account...' : 'Create account'}
          </Button>
        </form>
      </Form>
      <div className='relative'>
        <div className='absolute inset-0 flex items-center'>
          <span className='w-full border-t' />
        </div>
        <div className='relative flex justify-center text-xs uppercase'>
          <span className='bg-background text-muted-foreground px-2'>
            Or continue with
          </span>
        </div>
      </div>
      <Button variant='outline' type='button' disabled={isLoading}>
        <svg
          className='mr-2 h-4 w-4'
          aria-hidden='true'
          focusable='false'
          data-prefix='fab'
          data-icon='github'
          role='img'
          xmlns='http://www.w3.org/2000/svg'
          viewBox='0 0 496 512'>
          <path
            fill='currentColor'
            d='M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h240z'></path>
        </svg>
        Google
      </Button>
    </div>
  );
}

13. Create a Sign-Out Component

Create components/auth/sign-out-button.tsx:

'use client';
 
import { useState } from 'react';
 
import { Button } from '@/components/ui/button';
import { signOutAction } from '@/lib/actions/auth';
 
export function SignOutButton() {
  const [isLoading, setIsLoading] = useState(false);
 
  const handleSignOut = async () => {
    setIsLoading(true);
    await signOutAction();
  };
 
  return (
    <Button variant='outline' onClick={handleSignOut} disabled={isLoading}>
      {isLoading ? 'Signing out...' : 'Sign out'}
    </Button>
  );
}

14. Protect Routes with Middleware (First‑Layer Only)

Create middleware.ts in your project root:

import { NextRequest, NextResponse } from 'next/server';
 
import type { Session } from '@/utils/auth';
import { betterFetch } from '@better-fetch/fetch';
 
export async function middleware(request: NextRequest) {
  const host = request.headers.get('host') || 'domain.com';
  const protocol = request.headers.get('x-forwarded-proto') || 'https';
  const origin = `${protocol}://${host}`;
 
  let session: Session | null = null;
  const cookie = request.headers.get('cookie') || '';
 
  if (cookie) {
    session = await betterFetch<Session>('/api/auth/get-session', {
      baseURL: origin,
      headers: { cookie }
    })
      .then((res) => res.data)
      .catch(() => null);
  }
 
  const { pathname } = request.nextUrl;
 
  if (!session && pathname.startsWith('/admin')) {
    return NextResponse.redirect(new URL('/auth/sign-in', request.url));
  }
  if (session && pathname === '/auth/sign-in') {
    return NextResponse.redirect(new URL('/', request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ['/admin/:path*', '/auth/sign-in']
};

Note: This middleware uses your existing fetch logic (/api/auth/get-session) to redirect users early based on session presence.

Cautionary Notes

"Next.js's middleware is not a workaround for authentication... the authentication logic is handled separately on the backend."

"By adding a special header (x-middleware-subrequest), attackers can completely bypass middleware-based authentication checks."

"Middleware runs on every request… bad for performance to do database calls…"

Important: This middleware is useful only as an optimistic redirect layer. It does not guarantee security, and must never be your only line of defense.


Secure Page‑Level Guard (Required for Every Protected Route)

Every protected page (e.g. app/admin/page.tsx) must validate sessions again using secure backend calls:

import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
 
import { auth } from '@/lib/auth';
 
export default async function AdminPage() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect('/auth/sign-in');
  return <div>Welcome, {session.user.name}!</div>;
}

For role-based control:

// lib/server/requireAuth.ts
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
 
import { auth } from '@/lib/auth';
 
export async function requireAuth(requiredRoles: string[] = []) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect('/auth-sign-in');
 
  const roles = session.user?.roles.map((r) => r.name) || [];
  if (
    requiredRoles.length &&
    !requiredRoles.every((role) => roles.includes(role))
  ) {
    return { authorized: false, session };
  }
 
  return { authorized: true, session };
}

Use it like:

// app/admin/secure/page.tsx
import { requireAuth } from '@/lib/server/requireAuth';
 
export default async function SecurePage() {
  const { authorized, session } = await requireAuth(['ADMIN']);
  if (!authorized) return <div>Unauthorized</div>;
  return <div>Admin-only content for {session.user.name}</div>;
}

Why Two Layers of Protection?

You might wonder why we need both middleware and page-level guards. Here's why:

Middleware (First Layer):

Pros:

  • Provides optimistic redirects for better UX
  • Runs before the page loads, preventing unnecessary server-side rendering
  • Good for performance and user experience
  • Reduces server load by redirecting early

Cons:

  • Can be bypassed by attackers, so it's not secure by itself
  • Limited to redirect logic only
  • Runs on every request (performance overhead)
  • Cannot access database or complex logic

Page-Level Guards (Second Layer):

Pros:

  • Provides actual security through server-side validation
  • Validates sessions against your database on every request
  • Cannot be bypassed and ensures data integrity
  • Required for any sensitive operations or data access
  • Can implement complex authorization logic
  • Full access to server-side resources

Cons:

  • Requires server-side rendering for each protected page
  • Slightly slower initial page load
  • More code to maintain per protected route
  • Database calls on every request

Think of it like airport security:

  • Middleware = Quick ID check at the entrance (convenient but not foolproof)
  • Page guards = Full security screening before boarding (secure and mandatory)

Always implement both layers for a secure, performant authentication system.


15. Test Your App

Start your dev server:

pnpm dev

Visit http://localhost:3000/sign-in and test both email/password and Google sign-in flows.


16. Deploy

Deploy to Vercel or your preferred platform. Remember to set all environment variables in your deployment environment and update your Google OAuth callback URL.


Resources


Congratulations! You now have a Next.js app with robust authentication powered by Better Auth, featuring modern form handling with Zod validation, shadcn/ui components, react-hook-form, and server actions.