Skip to content

Better Auth Setup#

Complete authentication setup for Next.js with Better Auth - email/password login, signup, and session management.

Installation#

npm install better-auth

Database Setup#

Better Auth needs a database to store users and sessions. We'll use PostgreSQL with Drizzle ORM.

npm install drizzle-orm postgres
npm install -D drizzle-kit

Environment Variables#

# .env.local
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
BETTER_AUTH_SECRET="your-super-secret-key-change-this"
BETTER_AUTH_URL="http://localhost:3000"

Auth Configuration#

// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  emailAndPassword: {
    enabled: true,
    minPasswordLength: 8,
  },
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24,      // Update every 24 hours
  },
  advanced: {
    cookiePrefix: "my-app",
  },
});

Database Connection#

// lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);

export const db = drizzle(client);

API Route Handler#

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

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

This creates all auth endpoints: - POST /api/auth/sign-up/email - Signup - POST /api/auth/sign-in/email - Login - POST /api/auth/sign-out - Logout - GET /api/auth/get-session - Get current session

Client-Side Auth Hook#

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

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

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

Run Database Migrations#

Better Auth uses its own schema. Generate and run migrations:

# Generate schema
npx better-auth migrate

# Or manually create tables
npx drizzle-kit push

Better Auth will create these tables: - user - User accounts - session - Active sessions - account - OAuth accounts (if using OAuth) - verification - Email verification tokens

Usage in Components#

Get Session (Server Component)#

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

  if (!session) {
    redirect("/login");
  }

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

Get Session (Client Component)#

'use client'

import { useSession } from "@/lib/auth-client";

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

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

  if (!session) {
    return <a href="/login">Login</a>;
  }

  return (
    <div>
      <p>Logged in as {session.user.email}</p>
    </div>
  );
}

Complete Auth Flow#

1. Signup#

'use client'

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

export default function SignupForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const router = useRouter();

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

    const { data, error } = await signUp.email({
      email,
      password,
      name,
    });

    if (error) {
      alert(error.message);
      return;
    }

    // Redirect to dashboard
    router.push("/dashboard");
  };

  return (
    <form onSubmit={handleSignup}>
      <input
        type="text"
        placeholder="Name"
        value={name}
        onChange={(e) => setName(e.target.value)}
        required
      />
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      <button type="submit">Sign Up</button>
    </form>
  );
}

2. Login#

'use client'

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

export default function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const router = useRouter();

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

    const { data, error } = await signIn.email({
      email,
      password,
    });

    if (error) {
      alert(error.message);
      return;
    }

    router.push("/dashboard");
  };

  return (
    <form onSubmit={handleLogin}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      <button type="submit">Login</button>
    </form>
  );
}

3. Logout#

'use client'

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

export default function LogoutButton() {
  const router = useRouter();

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

  return (
    <button onClick={handleLogout}>
      Logout
    </button>
  );
}

Protected Routes (Server Side)#

// app/(protected)/layout.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    redirect("/login");
  }

  return <>{children}</>;
}

Now all pages in app/(protected)/ require authentication!

Middleware Protection (Alternative)#

// middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

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

  // Protect /dashboard routes
  if (request.nextUrl.pathname.startsWith("/dashboard")) {
    if (!session) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
  }

  // Redirect logged-in users away from auth pages
  if (request.nextUrl.pathname.startsWith("/login") || 
      request.nextUrl.pathname.startsWith("/signup")) {
    if (session) {
      return NextResponse.redirect(new URL("/dashboard", request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/login", "/signup"],
};

TypeScript Types#

// types/auth.ts
export interface User {
  id: string;
  email: string;
  name: string;
  emailVerified: boolean;
  image?: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface Session {
  session: {
    userId: string;
    expiresAt: Date;
  };
  user: User;
}

Environment Variables Reference#

# Required
DATABASE_URL="postgresql://..."
BETTER_AUTH_SECRET="random-secret-key"

# Optional
BETTER_AUTH_URL="http://localhost:3000"    # For production
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"

Best Practices#

  1. Use server-side session checks for protected pages
  2. Validate on server - Never trust client-side checks only
  3. Use middleware for route-level protection
  4. Handle errors gracefully - Show user-friendly messages
  5. Set strong secrets - Use long random strings
  6. Session expiry - Configure appropriate session length

Common Patterns#

Check Auth in Server Action#

// app/actions.ts
'use server'

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

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

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

  // Update user profile
  await db.update(users)
    .set({ name })
    .where(eq(users.id, session.user.id));

  return { success: true };
}

Conditional UI#

'use client'

import { useSession } from "@/lib/auth-client";

export default function Navbar() {
  const { data: session } = useSession();

  return (
    <nav>
      {session ? (
        <>
          <a href="/dashboard">Dashboard</a>
          <a href="/profile">Profile</a>
        </>
      ) : (
        <>
          <a href="/login">Login</a>
          <a href="/signup">Sign Up</a>
        </>
      )}
    </nav>
  );
}

Next Steps#