Complete Guide: Authentication & Authorization in Next.js App Router with Better Auth
You can also Follow the YouTube Tutorial Step-by-Step
Table of Contents
- Overview
- Prerequisites
- Project Setup
- UI Components Setup
- Authentication Pages
- Better Auth Installation
- Database Configuration (Prisma + Supabase)
- Better Auth Configuration
- API Route Setup
- Client Configuration
- Server Actions Implementation
- Social Authentication (Google & GitHub)
- Middleware Protection
- Dashboard Implementation
- Error Handling
- 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 UI → Auth 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 withDATABASE_URL
2. Setup Prisma Database
- Go to Prisma console
- Create a new project: "better-auth-starter-kit"
- Wait for setup to complete
- 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).*)",
],
};
Alternative: Optimistic Cookie-Based Middleware
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.