Auth UI Components#
Complete login and signup pages with Better Auth for Next.js.
Login Page#
// app/(auth)/login/page.tsx
'use client'
import { signIn } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
const { data, error: authError } = await signIn.email({
email,
password,
});
setIsLoading(false);
if (authError) {
setError(authError.message || "Invalid email or password");
return;
}
router.push("/dashboard");
router.refresh();
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<div>
<h2 className="text-3xl font-bold text-center">
Sign in to your account
</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
<p className="text-center text-sm text-gray-600">
Don't have an account?{" "}
<Link href="/signup" className="font-medium text-blue-600 hover:text-blue-500">
Sign up
</Link>
</p>
</form>
</div>
</div>
);
}
Signup Page#
// app/(auth)/signup/page.tsx
'use client'
import { signUp } from "@/lib/auth-client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function SignupPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validation
if (password !== confirmPassword) {
setError("Passwords don't match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setIsLoading(true);
const { data, error: authError } = await signUp.email({
email,
password,
name,
});
setIsLoading(false);
if (authError) {
setError(authError.message || "Failed to create account");
return;
}
router.push("/dashboard");
router.refresh();
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow">
<div>
<h2 className="text-3xl font-bold text-center">
Create your account
</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium">
Full name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md disabled:opacity-50"
>
{isLoading ? "Creating account..." : "Sign up"}
</button>
<p className="text-center text-sm text-gray-600">
Already have an account?{" "}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</Link>
</p>
</form>
</div>
</div>
);
}
Logout Button Component#
// components/auth/logout-button.tsx
'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");
router.refresh();
};
return (
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-red-600 hover:text-red-700"
>
Logout
</button>
);
}
User Avatar Dropdown#
// components/auth/user-dropdown.tsx
'use client'
import { useSession } from "@/lib/auth-client";
import LogoutButton from "./logout-button";
import Link from "next/link";
export default function UserDropdown() {
const { data: session, isPending } = useSession();
if (isPending) {
return <div className="h-8 w-8 rounded-full bg-gray-200 animate-pulse" />;
}
if (!session) {
return (
<div className="flex gap-4">
<Link href="/login" className="text-blue-600 hover:text-blue-700">
Login
</Link>
<Link href="/signup" className="text-blue-600 hover:text-blue-700">
Sign up
</Link>
</div>
);
}
return (
<div className="flex items-center gap-4">
<span className="text-sm text-gray-700">
{session.user.name || session.user.email}
</span>
<LogoutButton />
</div>
);
}
Protected Page Layout#
// app/(protected)/layout.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import UserDropdown from "@/components/auth/user-dropdown";
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/login");
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<h1 className="text-xl font-bold">My App</h1>
<UserDropdown />
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{children}
</main>
</div>
);
}
Dashboard Page#
// app/(protected)/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
return (
<div className="px-4 py-6">
<h1 className="text-3xl font-bold mb-4">
Welcome, {session?.user.name}!
</h1>
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Your Profile</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">Name</dt>
<dd className="text-sm text-gray-900">{session?.user.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="text-sm text-gray-900">{session?.user.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">User ID</dt>
<dd className="text-sm text-gray-900 font-mono">{session?.user.id}</dd>
</div>
</dl>
</div>
</div>
);
}
Form Validation with Zod#
// lib/validations.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
});
export const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
// Usage in form
import { loginSchema } from "@/lib/validations";
try {
const validated = loginSchema.parse({ email, password });
// Proceed with login
} catch (error) {
if (error instanceof z.ZodError) {
setError(error.errors[0].message);
}
}
Loading States#
// components/auth/loading-button.tsx
interface LoadingButtonProps {
isLoading: boolean;
children: React.ReactNode;
}
export default function LoadingButton({ isLoading, children }: LoadingButtonProps) {
return (
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Loading...
</div>
) : (
children
)}
</button>
);
}
Best Practices#
- ✅ Validate on both client and server - Use Zod schemas
- ✅ Show loading states - Disable buttons while submitting
- ✅ Handle errors gracefully - Display user-friendly messages
- ✅ Redirect after auth - Use router.push() and router.refresh()
- ✅ Use route groups -
(auth)and(protected)for organization - ✅ Style consistently - Use Tailwind CSS classes
- ✅ Accessibility - Add labels, ARIA attributes
Next Steps#
- Better Auth Setup - Authentication configuration
- Getting Started - Next.js project basics