17 KiB
Frontend Authentication with better-auth
This document provides guidelines for implementing client-side authentication using better-auth in a Next.js React application.
1. Overview
better-auth provides a comprehensive authentication solution for React applications with:
- Session Management: Cookie-based sessions with automatic refresh
- Multiple Auth Methods: Password, magic link, OAuth, and passkeys
- Type Safety: Full TypeScript support with inferred types
- Plugin Architecture: Extensible through plugins (2FA, organizations, admin, etc.)
Key Concepts
- Auth Client: The main interface for all authentication operations
- Session Context: React context for accessing session state across components
- Middleware: Server-side route protection before rendering
2. Auth Client Setup
Creating the Auth Client
Create a centralized auth client that can be imported throughout your application:
// packages/auth/client.ts
import {
adminClient,
inferAdditionalFields,
magicLinkClient,
organizationClient,
passkeyClient,
twoFactorClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { auth } from ".";
export const authClient = createAuthClient({
plugins: [
inferAdditionalFields<typeof auth>(),
magicLinkClient(),
organizationClient(),
adminClient(),
passkeyClient(),
twoFactorClient(),
],
});
export type AuthClientErrorCodes = typeof authClient.$ERROR_CODES & {
INVALID_INVITATION: string;
};
Configuration Options
The auth client supports various plugins based on your needs:
| Plugin | Purpose |
|---|---|
inferAdditionalFields |
Type inference for custom user fields |
magicLinkClient |
Passwordless email login |
organizationClient |
Multi-tenant organization support |
adminClient |
Admin user management |
passkeyClient |
WebAuthn/Passkey authentication |
twoFactorClient |
Two-factor authentication |
3. Session Hook/Context
Session Context Definition
Define the session context type and create the context:
// lib/session-context.ts
import type { Session } from "@your-app/auth"; // Replace with your monorepo package path
import React from "react";
export const SessionContext = React.createContext<
| {
session: Session["session"] | null;
user: Session["user"] | null;
loaded: boolean;
reloadSession: () => Promise<void>;
}
| undefined
>(undefined);
Session Provider Component
Wrap your application with a SessionProvider to manage session state:
// components/SessionProvider.tsx
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useEffect, useState } from "react";
import { SessionContext } from "../lib/session-context";
// Query key for session caching
export const sessionQueryKey = ["user", "session"] as const;
// Custom hook for fetching session
export const useSessionQuery = () => {
return useQuery({
queryKey: sessionQueryKey,
queryFn: async () => {
const { data, error } = await authClient.getSession({
query: {
disableCookieCache: true,
},
});
if (error) {
throw new Error(error.message || "Failed to fetch session");
}
return data;
},
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
retry: false,
});
};
export function SessionProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const { data: session } = useSessionQuery();
const [loaded, setLoaded] = useState(!!session);
useEffect(() => {
if (session && !loaded) {
setLoaded(true);
}
}, [session, loaded]);
return (
<SessionContext.Provider
value={{
loaded,
session: session?.session ?? null,
user: session?.user ?? null,
reloadSession: async () => {
const { data: newSession, error } = await authClient.getSession({
query: {
disableCookieCache: true,
},
});
if (error) {
throw new Error(error.message || "Failed to fetch session");
}
queryClient.setQueryData(sessionQueryKey, () => newSession);
},
}}
>
{children}
</SessionContext.Provider>
);
}
useSession Hook
Create a convenient hook to access session data:
// hooks/use-session.ts
import { useContext } from "react";
import { SessionContext } from "../lib/session-context";
export const useSession = () => {
const sessionContext = useContext(SessionContext);
if (sessionContext === undefined) {
throw new Error("useSession must be used within SessionProvider");
}
return sessionContext;
};
Usage Example
function UserGreeting() {
const { user, loaded } = useSession();
if (!loaded) {
return <div>Loading...</div>;
}
if (!user) {
return <div>Please log in</div>;
}
return <div>Welcome, {user.name}!</div>;
}
4. Protected Routes
Middleware for Route Protection
Use Next.js middleware to protect routes at the server level:
// middleware.ts
import { getSessionCookie } from "better-auth/cookies";
import { type NextRequest, NextResponse } from "next/server";
import { withQuery } from "ufo";
export default async function middleware(req: NextRequest) {
const { pathname, origin } = req.nextUrl;
const sessionCookie = getSessionCookie(req);
// Protect /app routes
if (pathname.startsWith("/app")) {
if (!sessionCookie) {
return NextResponse.redirect(
new URL(
withQuery("/auth/login", {
redirectTo: pathname,
}),
origin,
),
);
}
return NextResponse.next();
}
// Allow auth routes
if (pathname.startsWith("/auth")) {
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
Client-Side Route Protection
For additional client-side protection, redirect authenticated users away from auth pages:
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useSession } from "@/hooks/use-session";
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, loaded } = useSession();
const redirectPath = "/app/dashboard";
useEffect(() => {
if (loaded && user) {
router.replace(redirectPath);
}
}, [user, loaded, router]);
if (!loaded) {
return <LoadingSpinner />;
}
if (user) {
return null; // Will redirect
}
return <>{children}</>;
}
Loading States
Always handle loading states to prevent flash of unauthorized content:
function ProtectedContent() {
const { user, loaded } = useSession();
// Show loading while session is being fetched
if (!loaded) {
return (
<div className="flex items-center justify-center min-h-screen">
<Spinner />
</div>
);
}
// Redirect or show unauthorized message
if (!user) {
return <Redirect to="/auth/login" />;
}
return <DashboardContent user={user} />;
}
5. Login/Logout Flows
Email/Password Sign In
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
import { useRouter } from "next/navigation";
function LoginForm() {
const router = useRouter();
const onSubmit = async (values: { email: string; password: string }) => {
try {
const { data, error } = await authClient.signIn.email({
email: values.email,
password: values.password,
});
if (error) {
throw error;
}
// Handle 2FA redirect if enabled
if ((data as any).twoFactorRedirect) {
router.replace("/auth/verify");
return;
}
// Redirect to dashboard
router.replace("/app/dashboard");
} catch (e) {
// Handle error
console.error("Login failed:", e);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
Magic Link Sign In
const signInWithMagicLink = async (email: string) => {
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/app/dashboard",
});
if (error) {
throw error;
}
// Show success message - user will receive email
showNotification("Check your email for the login link");
};
OAuth Sign In
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
function SocialSigninButton({ provider }: { provider: string }) {
const redirectPath = "/app/dashboard";
const onSignin = () => {
const callbackURL = new URL(redirectPath, window.location.origin);
authClient.signIn.social({
provider, // "google", "github", etc.
callbackURL: callbackURL.toString(),
});
};
return (
<button onClick={onSignin}>
Sign in with {provider}
</button>
);
}
Passkey Sign In
const signInWithPasskey = async () => {
try {
await authClient.signIn.passkey();
router.replace("/app/dashboard");
} catch (e) {
console.error("Passkey authentication failed:", e);
}
};
Sign Out
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
const onLogout = () => {
authClient.signOut({
fetchOptions: {
onSuccess: async () => {
// Redirect to home or login page
window.location.href = new URL("/", window.location.origin).toString();
},
},
});
};
6. User Profile
Accessing Current User
Use the useSession hook to access user data:
function UserProfile() {
const { user, loaded } = useSession();
if (!loaded || !user) {
return null;
}
const { name, email, image } = user;
return (
<div className="flex items-center gap-2">
<img src={image} alt={name} className="w-10 h-10 rounded-full" />
<div>
<p className="font-medium">{name}</p>
<p className="text-sm text-gray-500">{email}</p>
</div>
</div>
);
}
Updating User Profile
"use client";
import { authClient } from "@your-app/auth/client"; // Replace with your monorepo package path
import { useSession } from "@/hooks/use-session";
function ChangeNameForm() {
const { user, reloadSession } = useSession();
const onSubmit = async ({ name }: { name: string }) => {
const { error } = await authClient.updateUser({
name,
});
if (error) {
showError("Failed to update name");
return;
}
showSuccess("Name updated successfully");
// Reload session to reflect changes
await reloadSession();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="text"
defaultValue={user?.name ?? ""}
{...register("name")}
/>
<button type="submit">Save</button>
</form>
);
}
Updating Other Profile Fields
// Update avatar
const updateAvatar = async (imageUrl: string) => {
const { error } = await authClient.updateUser({
image: imageUrl,
});
if (!error) {
await reloadSession();
}
};
// Update language preference (if custom field)
const updateLanguage = async (language: string) => {
const { error } = await authClient.updateUser({
language,
});
if (!error) {
await reloadSession();
}
};
7. Server-Side Session Access
For server components, access the session directly:
// lib/server.ts
import "server-only";
import { auth } from "@your-app/auth"; // Replace with your monorepo package path
import { headers } from "next/headers";
import { cache } from "react";
export const getSession = cache(async () => {
const session = await auth.api.getSession({
headers: await headers(),
query: {
disableCookieCache: true,
},
});
return session;
});
export const getActiveOrganization = cache(async (slug: string) => {
try {
const activeOrganization = await auth.api.getFullOrganization({
query: {
organizationSlug: slug,
},
headers: await headers(),
});
return activeOrganization;
} catch {
return null;
}
});
Usage in Server Components
// app/(app)/dashboard/page.tsx
import { getSession } from "@/lib/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getSession();
if (!session?.user) {
redirect("/auth/login");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
</div>
);
}
8. Best Practices
Always Check Session Before Protected Operations
function DeleteAccountButton() {
const { user, loaded } = useSession();
const handleDelete = async () => {
if (!loaded || !user) {
showError("Not authenticated");
return;
}
// Proceed with deletion
};
return (
<button onClick={handleDelete} disabled={!loaded || !user}>
Delete Account
</button>
);
}
Handle Loading States Properly
function AuthenticatedComponent() {
const { user, loaded } = useSession();
// Always handle loading state first
if (!loaded) {
return <Skeleton />;
}
// Then handle unauthenticated state
if (!user) {
return <LoginPrompt />;
}
// Finally render authenticated content
return <ProtectedContent user={user} />;
}
Proper Redirect After Auth
function LoginForm() {
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirectTo");
const onLoginSuccess = () => {
// Redirect to original destination or default
const destination = redirectTo ?? "/app/dashboard";
router.replace(destination);
};
}
Invalidate Session Cache After Auth Changes
import { useQueryClient } from "@tanstack/react-query";
function AuthComponent() {
const queryClient = useQueryClient();
const onAuthChange = () => {
// Invalidate session cache to trigger refetch
queryClient.invalidateQueries({
queryKey: sessionQueryKey,
});
};
}
Error Handling
const handleAuthError = (error: any) => {
// Get error code from better-auth error
const errorCode = error?.code;
// Map to user-friendly message
const errorMessages: Record<string, string> = {
INVALID_CREDENTIALS: "Invalid email or password",
USER_NOT_FOUND: "No account found with this email",
EMAIL_NOT_VERIFIED: "Please verify your email first",
TOO_MANY_REQUESTS: "Too many attempts. Please try again later",
};
const message = errorMessages[errorCode] ?? "An error occurred";
showError(message);
};
Security Considerations
- Never store sensitive auth data in localStorage - better-auth uses secure HTTP-only cookies
- Always validate sessions server-side - Middleware protection is essential
- Use HTTPS in production - Required for secure cookies
- Implement CSRF protection - better-auth handles this automatically
- Set appropriate session expiry - Configure in server auth options
9. Common Patterns
Conditional Rendering Based on Auth
function Navigation() {
const { user, loaded } = useSession();
return (
<nav>
<Link href="/">Home</Link>
{loaded && (
<>
{user ? (
<>
<Link href="/app/dashboard">Dashboard</Link>
<LogoutButton />
</>
) : (
<>
<Link href="/auth/login">Login</Link>
<Link href="/auth/signup">Sign Up</Link>
</>
)}
</>
)}
</nav>
);
}
Auth State Persistence Across Tabs
// Session is automatically synced via cookies
// For real-time sync, listen to storage events
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === "auth-sync") {
reloadSession();
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
Automatic Session Refresh
// Configure in useSessionQuery
export const useSessionQuery = () => {
return useQuery({
queryKey: sessionQueryKey,
queryFn: fetchSession,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
refetchOnWindowFocus: true,
});
};