Back to Guide

Tuesday, August 19, 2025

Authentication with Better auth

cover

Complete Guide: Authentication & Authorization in Next.js App Router with Better Auth

You can also Follow the YouTube Tutorial Step-by-Step

Watch the Youtube Video

Table of Contents

  1. Overview
  2. Prerequisites
  3. Project Setup
  4. UI Components Setup
  5. Authentication Pages
  6. Better Auth Installation
  7. Database Configuration (Prisma + Supabase)
  8. Better Auth Configuration
  9. API Route Setup
  10. Client Configuration
  11. Server Actions Implementation
  12. Social Authentication (Google & GitHub)
  13. Middleware Protection
  14. Dashboard Implementation
  15. Error Handling
  16. Role-Based Access Control

Overview

This guide follows the exact steps from the YouTube tutorial to implement complete authentication and authorization in Next.js App Router using Better Auth, Prisma ORM, and PostgreSQL (via Supabase).

What You'll Build

  • Complete authentication system with email/password
  • Social authentication (Google & GitHub OAuth)
  • User registration and login with validation
  • Protected routes using middleware
  • Role-based access control (Admin/User roles)
  • Dashboard with session management
  • Error handling and account linking
  • Beautiful UI components using Shadcn/UI

Tech Stack

  • Framework: Next.js 15+ with App Router
  • Authentication: Better Auth
  • Database: PostgreSQL (Supabase)
  • ORM: Prisma
  • UI: Shadcn/UI + Tailwind CSS
  • Form Validation: Zod + React Hook Form
  • Email Service: Resend (mentioned for production)

Prerequisites

  • Node.js 18.17.0 or later
  • Next.js 15+
  • Basic understanding of React and Next.js
  • Supabase account (for PostgreSQL database)
  • Google Cloud Console access (for OAuth)
  • GitHub Developer account (for OAuth)

Project Setup

1. Create New Next.js Project

pnpm create next-app@latest better-auth-starter-kit
cd better-auth-starter-kit

Choose the following options:

  • ✅ TypeScript
  • ✅ ESLint
  • ✅ Tailwind CSS
  • ✅ App Router
  • ✅ Turbo Pack

2. Install Shadcn/UI

pnpm dlx shadcn@latest init

Choose:

  • Style: New York
  • Color: Neutral

3. Install Required Shadcn Components

pnpm dlx shadcn@latest add button input form sonner

4. Install Additional Dependencies

# Better Auth
pnpm add better-auth

# Prisma ORM
pnpm add prisma @prisma/client

UI Components Setup

1. Project Structure

Create the following folder structure:

src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   │   └── page.tsx
│   │   ├── sign-up/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── (protected)/
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   └── api/
│       └── auth/
│           └── [...all]/
│               └── route.ts
├── components/
│   ├── auth/
│   ├── dashboard/
│   ├── front/
│   └── global/
├── lib/
│   ├── auth.ts
│   ├── auth-client.ts
│   └── db.ts
├── actions/
│   └── users.ts
└── prisma/
    └── schema.prisma

2. Get UI Components from Simple UI

Visit ReactUIComponents.com and navigate to:

  • Simple UIAuth UI
  • Copy the Login V2, Register V2 components

3. Create Reusable Components

Create src/components/global/app-icon.tsx:

import React from "react";

export function AppIcon() {
  return (
    <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
      <span className="text-sm font-bold text-primary-foreground">BA</span>
    </div>
  );
}

Create src/components/global/icons.tsx:

// Add your SVG icons here (Google, GitHub, etc.)
export function GoogleIcon() {
  return (
    <svg className="h-5 w-5" viewBox="0 0 24 24">
      {/* Google SVG path */}
    </svg>
  );
}

export function GitHubIcon() {
  return (
    <svg className="h-5 w-5" viewBox="0 0 24 24">
      {/* GitHub SVG path */}
    </svg>
  );
}

Authentication Pages

1. Create Auth Layout

Create src/app/(auth)/layout.tsx:

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      {children}
    </div>
  );
}

2. Sign Up Component

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

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
import { registerUser } from "@/actions/users";
import { AppIcon } from "@/components/global/app-icon";
import { GoogleIcon, GitHubIcon } from "@/components/global/icons";

const registerFormSchema = z.object({
  firstName: z.string().min(2, "First name must be at least 2 characters"),
  lastName: z.string().min(2, "Last name must be at least 2 characters"),
  email: z.string().email("Please enter a valid email"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
    .regex(/[a-z]/, "Password must contain at least one lowercase letter")
    .regex(/[0-9]/, "Password must contain at least one number"),
});

type RegisterFormValues = z.infer<typeof registerFormSchema>;

export function SignUp() {
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const form = useForm<RegisterFormValues>({
    resolver: zodResolver(registerFormSchema),
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
      password: "",
    },
  });

  const onSubmit = async (data: RegisterFormValues) => {
    setLoading(true);
    try {
      const result = await registerUser(data);

      if (result.success) {
        toast.success("Account created successfully!");
        router.push("/dashboard");
      } else {
        if (result.status === "UNPROCESSABLE_ENTITY") {
          toast.error("Email already exists");
        } else {
          toast.error(result.error || "Something went wrong");
        }
      }
    } catch (error) {
      toast.error("An unexpected error occurred");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="w-full max-w-md space-y-8">
      <div className="text-center">
        <AppIcon />
        <h2 className="mt-6 text-3xl font-bold">Create your account</h2>
        <p className="mt-2 text-sm text-gray-600">
          Or{" "}
          <a
            href="/login"
            className="font-medium text-indigo-600 hover:text-indigo-500"
          >
            sign in to your account
          </a>
        </p>
      </div>

      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <div className="grid grid-cols-2 gap-4">
            <FormField
              control={form.control}
              name="firstName"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>First name</FormLabel>
                  <FormControl>
                    <Input placeholder="John" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="lastName"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Last name</FormLabel>
                  <FormControl>
                    <Input placeholder="Doe" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>

          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email address</FormLabel>
                <FormControl>
                  <Input
                    placeholder="john@example.com"
                    type="email"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="password"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input
                    placeholder="Enter your password"
                    type="password"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? "Creating account..." : "Create account"}
          </Button>
        </form>
      </Form>

      <div className="space-y-3">
        <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 px-2 text-muted-foreground">
              Or continue with
            </span>
          </div>
        </div>

        <div className="grid grid-cols-2 gap-3">
          <Button variant="outline" type="button">
            <GoogleIcon />
            Google
          </Button>
          <Button variant="outline" type="button">
            <GitHubIcon />
            GitHub
          </Button>
        </div>
      </div>
    </div>
  );
}

3. Login Component

Create src/components/auth/login.tsx:

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
import { loginUser } from "@/actions/users";
import { AppIcon } from "@/components/global/app-icon";

const loginFormSchema = z.object({
  email: z.string().email("Please enter a valid email"),
  password: z.string().min(1, "Password is required"),
});

type LoginFormValues = z.infer<typeof loginFormSchema>;

export function Login() {
  const [loading, setLoading] = useState(false);
  const router = useRouter();

  const form = useForm<LoginFormValues>({
    resolver: zodResolver(loginFormSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  const onSubmit = async (data: LoginFormValues) => {
    setLoading(true);
    try {
      const result = await loginUser(data);

      if (result.success) {
        toast.success("Login successful!");
        router.push("/dashboard");
      } else {
        if (result.status === "UNAUTHORIZED") {
          toast.error("Wrong credentials");
        } else {
          toast.error(result.error || "Something went wrong");
        }
      }
    } catch (error) {
      toast.error("An unexpected error occurred");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="w-full max-w-md space-y-8">
      <div className="text-center">
        <AppIcon />
        <h2 className="mt-6 text-3xl font-bold">Sign in to your account</h2>
        <p className="mt-2 text-sm text-gray-600">
          Or{" "}
          <a
            href="/sign-up"
            className="font-medium text-indigo-600 hover:text-indigo-500"
          >
            create a new account
          </a>
        </p>
      </div>

      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email address</FormLabel>
                <FormControl>
                  <Input
                    placeholder="john@example.com"
                    type="email"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="password"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Password</FormLabel>
                <FormControl>
                  <Input
                    placeholder="Enter your password"
                    type="password"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <Button type="submit" className="w-full" disabled={loading}>
            {loading ? "Signing in..." : "Sign in"}
          </Button>
        </form>
      </Form>
    </div>
  );
}

4. Page Files

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

import { SignUp } from "@/components/auth/sign-up";

export default function SignUpPage() {
  return <SignUp />;
}

Create src/app/(auth)/login/page.tsx:

import { Login } from "@/components/auth/login";

export default function LoginPage() {
  return <Login />;
}

Better Auth Installation

# Install Better Auth
pnpm add better-auth

Database Configuration

1. Initialize Prisma

npx prisma init

This creates:

  • prisma/schema.prisma
  • .env file with DATABASE_URL

2. Setup Prisma Database

  1. Go to Prisma console
  2. Create a new project: "better-auth-starter-kit"
  3. Wait for setup to complete
  4. Copy the DATABASE URL into .env

3. Environment Variables

Create/update .env:

# Better Auth
BETTER_AUTH_SECRET="your-secret-key-32-characters-long"
BETTER_AUTH_URL="http://localhost:3000"

# Database (from Prisma)
DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqd3RfaWQiOjEsInNlY3VyZV9rZXkiOiJza19NN3BQc

# OAuth Providers (add later)
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""

Generate Secret:

openssl rand -base64 32

4. Create Prisma Database Instance

Create src/lib/db.ts:

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const db = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

5. Configure Prisma Schema

Update prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
  output   = "../src/lib/generated/prisma"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

enum UserRole {
  ADMIN
  USER
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified Boolean   @default(false)
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  firstName     String?
  lastName      String?
  role          UserRole  @default(USER)

  sessions Session[]
  accounts Account[]

  @@map("user")
}

model Session {
  id        String   @id @default(cuid())
  expiresAt DateTime
  token     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  ipAddress String?
  userAgent String?

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("session")
}

model Account {
  id                    String    @id @default(cuid())
  accountId             String
  providerId            String
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("account")
}

model Verification {
  id         String   @id @default(cuid())
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  @@map("verification")
}

Better Auth Configuration

1. Create Auth Configuration

Create src/lib/auth.ts:

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { db } from "./db";
import { nextCookies } from "better-auth/next-js";

export const auth = betterAuth({
  // Database adapter
  database: prismaAdapter(db, {
    provider: "postgresql",
  }),

  // Email & Password Authentication
  emailAndPassword: {
    enabled: true,
    autoSignIn: true,
    minPasswordLength: 8,
  },

  // Account linking
  account: {
    accountLinking: {
      enabled: true,
    },
  },

  // Social Providers
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
      // Map profile to user fields
      mapProfile: {
        firstName: "name",
        lastName: "",
      },
    },
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
      // Map profile to user fields
      mapProfile: {
        firstName: "given_name",
        lastName: "family_name",
      },
    },
  },

  // Extend user schema
  user: {
    role: {
      type: "string",
      required: true,
      defaultValue: "USER",
      input: false, // Don't allow users to set their role
    },
    firstName: {
      type: "string",
      required: true,
    },
    lastName: {
      type: "string",
      required: true,
    },
  },

  // Security Configuration
  secret: process.env.BETTER_AUTH_SECRET as string,
  baseURL: process.env.BETTER_AUTH_URL as string,

  // Plugins - IMPORTANT: nextCookies should be last
  plugins: [
    nextCookies(), // Automatically sets cookies for server actions
  ],
});

// Export types for TypeScript
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.User;

6. Generate Better Auth Schema

npx better-auth-cli generate

When prompted:

  • Do you want to overwrite this schema file? → Yes

This command adds the required tables for Better Auth to your schema.

7. Generate Prisma Client

npx prisma generate

8. Run Database Migration

npx prisma migrate dev --name init

This creates the tables in your Supabase database.

API Route Setup

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

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

Client Configuration

Create src/lib/auth-client.ts:

import { createAuthClient } from "better-auth/react";
import type { auth } from "./auth";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});

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

Environment Variables

Create .env.local:

# Database
DATABASE_URL="postgresql://username:password@localhost:5432/database"

# Better Auth
BETTER_AUTH_SECRET="your-super-secret-key-at-least-32-characters"
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"

# Google OAuth (optional)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

# GitHub OAuth (optional)
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"

Generate Secret Key

openssl rand -base64 32

Authentication Pages

Sign In Page

Create src/app/sign-in/page.tsx:

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";

export default function SignInPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const router = useRouter();

  const handleEmailSignIn = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError("");

    try {
      const result = await signIn.email({
        email,
        password,
      });

      if (result.error) {
        setError(result.error.message);
      } else {
        router.push("/dashboard");
      }
    } catch (err) {
      setError("An unexpected error occurred");
    } finally {
      setLoading(false);
    }
  };

  const handleGoogleSignIn = async () => {
    setLoading(true);
    try {
      await signIn.social({
        provider: "google",
        callbackURL: "/dashboard",
      });
    } catch (err) {
      setError("Google sign-in failed");
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Sign in to your account
          </h2>
        </div>

        <form className="mt-8 space-y-6" onSubmit={handleEmailSignIn}>
          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
              {error}
            </div>
          )}

          <div>
            <input
              id="email"
              name="email"
              type="email"
              required
              className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="Email address"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>

          <div>
            <input
              id="password"
              name="password"
              type="password"
              required
              className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="Password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>

          <div>
            <button
              type="submit"
              disabled={loading}
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
            >
              {loading ? "Signing in..." : "Sign in"}
            </button>
          </div>

          <div>
            <button
              type="button"
              onClick={handleGoogleSignIn}
              disabled={loading}
              className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
            >
              Sign in with Google
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

Sign Up Page

Create src/app/sign-up/page.tsx:

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signUp } from "@/lib/auth-client";

export default function SignUpPage() {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    password: "",
    confirmPassword: "",
  });
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (formData.password !== formData.confirmPassword) {
      setError("Passwords do not match");
      return;
    }

    setLoading(true);
    setError("");

    try {
      const result = await signUp.email({
        email: formData.email,
        password: formData.password,
        name: formData.name,
      });

      if (result.error) {
        setError(result.error.message);
      } else {
        router.push("/dashboard");
      }
    } catch (err) {
      setError("An unexpected error occurred");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Create your account
          </h2>
        </div>

        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          {error && (
            <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
              {error}
            </div>
          )}

          <div className="space-y-4">
            <input
              type="text"
              required
              className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="Full name"
              value={formData.name}
              onChange={(e) =>
                setFormData({ ...formData, name: e.target.value })
              }
            />

            <input
              type="email"
              required
              className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="Email address"
              value={formData.email}
              onChange={(e) =>
                setFormData({ ...formData, email: e.target.value })
              }
            />

            <input
              type="password"
              required
              className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="Password"
              value={formData.password}
              onChange={(e) =>
                setFormData({ ...formData, password: e.target.value })
              }
            />

            <input
              type="password"
              required
              className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="Confirm password"
              value={formData.confirmPassword}
              onChange={(e) =>
                setFormData({ ...formData, confirmPassword: e.target.value })
              }
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
          >
            {loading ? "Creating account..." : "Sign up"}
          </button>
        </form>
      </div>
    </div>
  );
}

Protecting Routes with Middleware

Create src/middleware.ts:

import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

// Define protected and public routes
const protectedRoutes = ["/dashboard", "/profile", "/admin"];
const authRoutes = ["/sign-in", "/sign-up"];

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Check if the route is protected
  const isProtectedRoute = protectedRoutes.some((route) =>
    pathname.startsWith(route)
  );

  // Check if the route is an auth route
  const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));

  if (isProtectedRoute) {
    try {
      // Get session using Better Auth
      const session = await auth.api.getSession({
        headers: await headers(),
      });

      if (!session) {
        return NextResponse.redirect(
          new URL("/sign-in?redirect=" + pathname, request.url)
        );
      }

      // Role-based access control
      if (pathname.startsWith("/admin")) {
        // @ts-ignore - Better Auth doesn't expose user roles in session by default
        if (session.user.role !== "admin") {
          return NextResponse.redirect(new URL("/dashboard", request.url));
        }
      }
    } catch (error) {
      console.error("Middleware auth error:", error);
      return NextResponse.redirect(new URL("/sign-in", request.url));
    }
  }

  // Redirect authenticated users away from auth pages
  if (isAuthRoute) {
    try {
      const session = await auth.api.getSession({
        headers: await headers(),
      });

      if (session) {
        return NextResponse.redirect(new URL("/dashboard", request.url));
      }
    } catch (error) {
      // If there's an error getting session, continue to auth page
      console.error("Middleware auth check error:", error);
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

For better performance, you can use cookie-based checks:

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

const protectedRoutes = ["/dashboard", "/profile", "/admin"];
const authRoutes = ["/sign-in", "/sign-up"];

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  const isProtectedRoute = protectedRoutes.some((route) =>
    pathname.startsWith(route)
  );

  const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));

  if (isProtectedRoute) {
    const sessionCookie = getSessionCookie(request);

    if (!sessionCookie) {
      return NextResponse.redirect(
        new URL("/sign-in?redirect=" + pathname, request.url)
      );
    }
  }

  if (isAuthRoute) {
    const sessionCookie = getSessionCookie(request);

    if (sessionCookie) {
      return NextResponse.redirect(new URL("/dashboard", request.url));
    }
  }

  return NextResponse.next();
}

Server-Side Authentication

In Server Components

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(),
  });

  if (!session) {
    redirect("/sign-in");
  }

  return (
    <div>
      <h1>Welcome, {session.user.name}!</h1>
      <p>Email: {session.user.email}</p>
    </div>
  );
}

In API Routes

import { auth } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Your protected API logic here
  return NextResponse.json({
    message: "This is protected data",
    user: session.user,
  });
}

In Server Actions

"use server";

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export async function updateProfile(formData: FormData) {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    throw new Error("Unauthorized");
  }

  // Your server action logic here
  const name = formData.get("name") as string;

  // Update user profile...

  return { success: true };
}

Client-Side Authentication

Using the useSession Hook

"use client";

import { useSession, signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";

export default function Dashboard() {
  const { data: session, isPending } = useSession();
  const router = useRouter();

  const handleSignOut = async () => {
    await signOut();
    router.push("/");
  };

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (!session) {
    router.push("/sign-in");
    return null;
  }

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome, {session.user.name}!</p>
      <button onClick={handleSignOut}>Sign Out</button>
    </div>
  );
}

Protected Component Wrapper

"use client";

import { useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

interface ProtectedProps {
  children: React.ReactNode;
  fallback?: React.ReactNode;
  requiredRole?: string;
}

export function Protected({
  children,
  fallback = <div>Access denied</div>,
  requiredRole,
}: ProtectedProps) {
  const { data: session, isPending } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (!isPending && !session) {
      router.push("/sign-in");
    }
  }, [session, isPending, router]);

  if (isPending) {
    return <div>Loading...</div>;
  }

  if (!session) {
    return null;
  }

  // Role-based access control
  if (requiredRole && session.user.role !== requiredRole) {
    return fallback;
  }

  return <>{children}</>;
}

Authorization & Role-Based Access

Database Schema Updates

Add role fields to your user schema:

export const user = pgTable("user", {
  // ... existing fields
  role: text("role").notNull().default("user"),
  permissions: text("permissions").array(), // PostgreSQL array
});

Enhanced Auth Configuration

export const auth = betterAuth({
  // ... existing config

  callbacks: {
    async signIn({ user, account }) {
      // Set default role for new users
      if (!user.role) {
        user.role = "user";
      }
      return true;
    },

    async session({ session, user }) {
      // Include role in session
      return {
        ...session,
        user: {
          ...session.user,
          role: user.role,
          permissions: user.permissions,
        },
      };
    },
  },
});

Role-Based Middleware

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // Admin routes
  if (pathname.startsWith("/admin")) {
    const session = await auth.api.getSession({
      headers: await headers(),
    });

    if (!session) {
      return NextResponse.redirect(new URL("/sign-in", request.url));
    }

    if (session.user.role !== "admin") {
      return NextResponse.redirect(new URL("/unauthorized", request.url));
    }
  }

  // Manager routes
  if (pathname.startsWith("/manager")) {
    const session = await auth.api.getSession({
      headers: await headers(),
    });

    if (!session) {
      return NextResponse.redirect(new URL("/sign-in", request.url));
    }

    if (!["admin", "manager"].includes(session.user.role)) {
      return NextResponse.redirect(new URL("/unauthorized", request.url));
    }
  }

  return NextResponse.next();
}

Permission-Based Access Control

// lib/permissions.ts
export const permissions = {
  users: {
    read: "users.read",
    write: "users.write",
    delete: "users.delete",
  },
  posts: {
    read: "posts.read",
    write: "posts.write",
    delete: "posts.delete",
  },
} as const;

export function hasPermission(
  userPermissions: string[],
  requiredPermission: string
): boolean {
  return userPermissions.includes(requiredPermission);
}

// Usage in components
export function UserManagement() {
  const { data: session } = useSession();

  const canDeleteUsers = hasPermission(
    session?.user.permissions || [],
    permissions.users.delete
  );

  return <div>{canDeleteUsers && <button>Delete User</button>}</div>;
}

Subscribe to My Latest Guides

All the latest Guides and tutorials, straight from the team.

Subscribe to be the first to receive the new Guide when it comes out

Get All Guides at Once