Loading…
Loading…
Implementing secure, session-based authentication in Next.js App Router using the comprehensive Better Auth library.
Tags
Better Auth provides a type-safe, database-agnostic authentication solution that integrates seamlessly with Next.js App Router, supporting OAuth, email/password, and MFA out of the box.
npm install better-authCreate the Better Auth configuration file. This is the central configuration that defines your auth behavior.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh session every 24 hours
},
});Set up your environment variables:
DATABASE_URL=postgresql://user:password@localhost:5432/myapp
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
BETTER_AUTH_SECRET=your-random-secret-at-least-32-chars
BETTER_AUTH_URL=http://localhost:3000
Better Auth handles all authentication endpoints through a single catch-all route.
// 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 single file handles all auth routes automatically:
POST /api/auth/sign-up/email -- email/password registrationPOST /api/auth/sign-in/email -- email/password loginGET /api/auth/sign-in/social?provider=google -- OAuth redirectPOST /api/auth/sign-out -- session invalidationGET /api/auth/session -- current session checkCreate a client instance that provides React hooks for authentication state.
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const { signIn, signUp, signOut, useSession } = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});// components/SignUpForm.tsx
"use client";
import { signUp } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function SignUpForm() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const { error } = await signUp.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
name: formData.get("name") as string,
});
if (error) {
setError(error.message || "Something went wrong");
setLoading(false);
return;
}
router.push("/dashboard");
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required minLength={8} />
</div>
{error && <p className="text-red-500">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Creating account..." : "Sign Up"}
</button>
</form>
);
}// components/SignInForm.tsx
"use client";
import { signIn } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function SignInForm() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleEmailSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const { error } = await signIn.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) {
setError(error.message || "Invalid credentials");
setLoading(false);
return;
}
router.push("/dashboard");
};
const handleOAuthSignIn = async (provider: "google" | "github") => {
await signIn.social({
provider,
callbackURL: "/dashboard",
});
};
return (
<div>
<form onSubmit={handleEmailSignIn}>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
</div>
{error && <p className="text-red-500">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<div className="divider">or continue with</div>
<div className="oauth-buttons">
<button onClick={() => handleOAuthSignIn("google")}>
Sign in with Google
</button>
<button onClick={() => handleOAuthSignIn("github")}>
Sign in with GitHub
</button>
</div>
</div>
);
}Use the useSession hook in client components to access the current user.
// components/UserMenu.tsx
"use client";
import { useSession, signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
export function UserMenu() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) return <div>Loading...</div>;
if (!session) {
return <button onClick={() => router.push("/sign-in")}>Sign In</button>;
}
return (
<div className="user-menu">
<span>Welcome, {session.user.name}</span>
<button
onClick={async () => {
await signOut();
router.push("/");
}}
>
Sign Out
</button>
</div>
);
}Access the session in Server Components and Route Handlers using the auth instance directly.
// 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("/sign-in");
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome back, {session.user.name}!</p>
<p>Email: {session.user.email}</p>
</div>
);
}Protect entire route groups using Next.js middleware.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const protectedRoutes = ["/dashboard", "/settings", "/profile"];
const authRoutes = ["/sign-in", "/sign-up"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = await auth.api.getSession({
headers: request.headers,
});
// Redirect unauthenticated users away from protected routes
const isProtected = protectedRoutes.some((route) => pathname.startsWith(route));
if (isProtected && !session) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
// Redirect authenticated users away from auth pages
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
if (isAuthRoute && session) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/profile/:path*", "/sign-in", "/sign-up"],
};Better Auth automatically creates the required tables when you first run the application. For production, generate and review migrations.
npx better-auth generateThis creates SQL migration files that you can review and apply to your database. The generated schema includes tables for users, sessions, accounts (for OAuth), and verification tokens.
Extend the user model with additional fields:
// lib/auth.ts
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL,
}),
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
required: false,
},
bio: {
type: "string",
required: false,
},
},
},
// ... rest of config
});Better Auth includes built-in TOTP-based MFA. Enable it in your configuration.
// lib/auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
// ... base config
plugins: [
twoFactor({
issuer: "MyApp", // Shows in authenticator apps
}),
],
});Update the client to include the two-factor plugin:
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";
export const {
signIn,
signUp,
signOut,
useSession,
twoFactor,
} = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
plugins: [twoFactorClient()],
});// components/EnableMFA.tsx
"use client";
import { twoFactor } from "@/lib/auth-client";
import { useState } from "react";
export function EnableMFA() {
const [qrCode, setQrCode] = useState<string | null>(null);
const [verificationCode, setVerificationCode] = useState("");
const handleEnable = async () => {
const { data } = await twoFactor.enable();
if (data?.totpURI) {
// Convert TOTP URI to QR code using a library like qrcode
setQrCode(data.totpURI);
}
};
const handleVerify = async () => {
const { error } = await twoFactor.verifyTotp({
code: verificationCode,
});
if (!error) {
alert("MFA enabled successfully!");
}
};
return (
<div>
{!qrCode ? (
<button onClick={handleEnable}>Enable Two-Factor Auth</button>
) : (
<div>
<p>Scan this QR code with your authenticator app:</p>
<code className="block text-sm break-all">{qrCode}</code>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
/>
<button onClick={handleVerify}>Verify</button>
</div>
)}
</div>
);
}Your authentication system now covers the full spectrum:
Better Auth manages the database schema, session lifecycle, and token rotation. You focus on building the UI and defining which routes need protection.
role field to restrict pages and API routes by user roleBetter Auth is a type-safe, database-agnostic authentication library that provides a more comprehensive and developer-friendly API than NextAuth, with built-in support for session cookies, OAuth, and MFA.
Better Auth uses standard session cookies for authentication, which are automatically managed and validated on both client and server, making it compatible with Next.js App Router's server components.
Yes, Better Auth includes built-in support for multi-factor authentication (MFA), allowing you to add TOTP-based or other MFA methods to your authentication flow without additional libraries.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.