Resend Setup
Configure Resend for transactional emails like password resets and email verification.
MCP Server
Add the Resend MCP server to your .cursor/mcp.json for accurate API guidance:
{
"mcpServers": {
"resend": {
"url": "https://resend.com/docs/mcp"
}
}
}{
"mcpServers": {
"resend": {
"url": "https://resend.com/docs/mcp"
}
}
}Step 1: Install the packages
bun add resend @react-email/componentsbun add resend @react-email/componentsThe @react-email/components package is required for Resend to render React email templates.
Step 2: Add environment variables
Add to your .env.development:
RESEND_API_KEY="re_your_api_key"
RESEND_FROM_EMAIL="Your App <noreply@yourdomain.com>"RESEND_API_KEY="re_your_api_key"
RESEND_FROM_EMAIL="Your App <noreply@yourdomain.com>"Then sync to Vercel with bun run env:push.
Get your API key from resend.com/api-keys.
Step 3: Create the resend config
Create the Resend config with email format validation:
import { z } from "zod";
import { loadConfig } from "../common/load-config";
export const resendConfig = loadConfig({
server: {
apiKey: process.env.RESEND_API_KEY,
fromEmail: {
value: process.env.RESEND_FROM_EMAIL,
schema: z
.string()
.regex(
/^.+\s<.+@.+\..+>$/,
'Must match "Name <email@domain.com>" format.',
),
},
},
});import { z } from "zod";
import { loadConfig } from "../common/load-config";
export const resendConfig = loadConfig({
server: {
apiKey: process.env.RESEND_API_KEY,
fromEmail: {
value: process.env.RESEND_FROM_EMAIL,
schema: z
.string()
.regex(
/^.+\s<.+@.+\..+>$/,
'Must match "Name <email@domain.com>" format.',
),
},
},
});Step 4: Create the Resend client
Create the Resend client instance:
import { Resend } from "resend";
import { resendConfig } from "./config";
export const resend = new Resend(resendConfig.server.apiKey);import { Resend } from "resend";
import { resendConfig } from "./config";
export const resend = new Resend(resendConfig.server.apiKey);Step 5: Create the send helper
Create the email sending helper:
import { resend } from "./client";
import { resendConfig } from "./config";
type SendEmailParams = {
to: string | string[];
subject: string;
react: React.ReactElement;
from?: string;
};
export async function sendEmail({ to, subject, react, from }: SendEmailParams) {
const { data, error } = await resend.emails.send({
from: from ?? resendConfig.server.fromEmail,
to: Array.isArray(to) ? to : [to],
subject,
react,
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
return data;
}import { resend } from "./client";
import { resendConfig } from "./config";
type SendEmailParams = {
to: string | string[];
subject: string;
react: React.ReactElement;
from?: string;
};
export async function sendEmail({ to, subject, react, from }: SendEmailParams) {
const { data, error } = await resend.emails.send({
from: from ?? resendConfig.server.fromEmail,
to: Array.isArray(to) ? to : [to],
subject,
react,
});
if (error) {
throw new Error(`Failed to send email: ${error.message}`);
}
return data;
}Usage
import { sendEmail } from "@/lib/resend/send";
await sendEmail({
to: "user@example.com",
subject: "Welcome!",
react: <WelcomeEmail name="John" />,
});import { sendEmail } from "@/lib/resend/send";
await sendEmail({
to: "user@example.com",
subject: "Welcome!",
react: <WelcomeEmail name="John" />,
});Create email templates
Email templates are React components colocated with the feature that uses them, following the "everything is a library" pattern. Auth-related emails (password reset, email verification) live in src/lib/auth/emails/, while other features would have their own emails/ subfolder.
For example, a password reset email template:
interface ForgotPasswordEmailProps {
resetLink: string;
}
export function ForgotPasswordEmail({ resetLink }: ForgotPasswordEmailProps) {
return (
<div>
<h1>Reset Your Password</h1>
<p>Click the link below to reset your password:</p>
<a href={resetLink}>Reset Password</a>
<p>If you did not request a password reset, please ignore this email.</p>
</div>
);
}interface ForgotPasswordEmailProps {
resetLink: string;
}
export function ForgotPasswordEmail({ resetLink }: ForgotPasswordEmailProps) {
return (
<div>
<h1>Reset Your Password</h1>
<p>Click the link below to reset your password:</p>
<a href={resetLink}>Reset Password</a>
<p>If you did not request a password reset, please ignore this email.</p>
</div>
);
}File Structure
src/lib/resend/
config.ts # Environment validation
client.ts # Resend client instance
send.ts # Email sending helper
src/lib/auth/emails/
forgot-password.tsx # Password reset templatesrc/lib/resend/
config.ts # Environment validation
client.ts # Resend client instance
send.ts # Email sending helper
src/lib/auth/emails/
forgot-password.tsx # Password reset templateReferences
Better Auth Setup
Add user authentication using Better Auth with Drizzle ORM and Neon Postgres. Base setup with email/password authentication.
MCP Server
Add the Better Auth MCP server to your .cursor/mcp.json for accurate API guidance:
{
"mcpServers": {
"better-auth": {
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp"
}
}
}{
"mcpServers": {
"better-auth": {
"url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp"
}
}
}Step 1: Install the package
bun add better-authbun add better-authStep 2: Add environment variables
Add the secret to your .env.development (synced to Vercel):
BETTER_AUTH_SECRET="your-random-secret-at-least-32-chars"BETTER_AUTH_SECRET="your-random-secret-at-least-32-chars"Generate a secret using:
openssl rand -base64 32openssl rand -base64 32Add the URL to your .env.local (local override):
BETTER_AUTH_URL="http://localhost:3000"BETTER_AUTH_URL="http://localhost:3000"The BETTER_AUTH_URL differs between local (http://localhost:3000) and deployed environments, so it belongs in .env.local. On Vercel, set BETTER_AUTH_URL to your production URL in the dashboard.
Step 3: Create the auth config
Create the auth config following the Environment Variable Management pattern:
import { loadConfig } from "../common/load-config";
export const authConfig = loadConfig({
server: {
secret: process.env.BETTER_AUTH_SECRET,
url: process.env.BETTER_AUTH_URL,
},
});import { loadConfig } from "../common/load-config";
export const authConfig = loadConfig({
server: {
secret: process.env.BETTER_AUTH_SECRET,
url: process.env.BETTER_AUTH_URL,
},
});Step 4: Update the generate script
Update scripts/db/generate-schema.ts to generate the Better Auth schema before running Drizzle migrations:
import { $ } from "bun";
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
await $`bunx @better-auth/cli@latest generate --config src/lib/auth/server.tsx --output src/lib/auth/schema.ts`;
await $`drizzle-kit generate`;import { $ } from "bun";
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
await $`bunx @better-auth/cli@latest generate --config src/lib/auth/server.tsx --output src/lib/auth/schema.ts`;
await $`drizzle-kit generate`;The Better Auth CLI generates schema.ts from your server config. Running it before drizzle-kit generate ensures your auth schema is always in sync when creating Drizzle migrations.
See Neon + Drizzle Setup for the initial script setup and package.json scripts.
Step 5: Create the auth server instance
Create the auth server with basic email/password authentication:
Note: We use
.tsxinstead of.tsto support JSX email templates when you add Better Auth Emails later.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/client";
import { authConfig } from "./config";
export const auth = betterAuth({
secret: authConfig.server.secret,
baseURL: authConfig.server.url,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
},
});import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/client";
import { authConfig } from "./config";
export const auth = betterAuth({
secret: authConfig.server.secret,
baseURL: authConfig.server.url,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
},
});Step 6: Create the API route handler
Create the catch-all route handler for auth:
import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);Step 7: Create the auth client
Create the client-side auth hooks:
"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export const { signIn, signUp, signOut, useSession } = authClient;"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export const { signIn, signUp, signOut, useSession } = authClient;Step 8: Generate and run migrations
bun run db:generate
bun run db:migratebun run db:generate
bun run db:migrateUsage
Sign Up
import { signUp } from "@/lib/auth/client";
await signUp.email({
email: "user@example.com",
password: "securepassword",
name: "John Doe",
});import { signUp } from "@/lib/auth/client";
await signUp.email({
email: "user@example.com",
password: "securepassword",
name: "John Doe",
});Sign In
import { signIn } from "@/lib/auth/client";
await signIn.email({
email: "user@example.com",
password: "securepassword",
});import { signIn } from "@/lib/auth/client";
await signIn.email({
email: "user@example.com",
password: "securepassword",
});Sign Out
import { signOut } from "@/lib/auth/client";
await signOut();import { signOut } from "@/lib/auth/client";
await signOut();Get Session (Client)
"use client";
import { useSession } from "@/lib/auth/client";
export function UserProfile() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <div>Not signed in</div>;
return <div>Hello, {session.user.name}</div>;
}"use client";
import { useSession } from "@/lib/auth/client";
export function UserProfile() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <div>Not signed in</div>;
return <div>Hello, {session.user.name}</div>;
}Get Session (Server)
import { auth } from "@/lib/auth/server";
import { headers } from "next/headers";
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return <div>Not signed in</div>;
}
return <div>Hello, {session.user.name}</div>;
}import { auth } from "@/lib/auth/server";
import { headers } from "next/headers";
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return <div>Not signed in</div>;
}
return <div>Hello, {session.user.name}</div>;
}Protected Page Pattern
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function ProtectedPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return <div>Welcome, {session.user.name}</div>;
}import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function ProtectedPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return <div>Welcome, {session.user.name}</div>;
}File Structure
src/lib/auth/
config.ts # Environment validation
schema.ts # Auto-generated by Better Auth CLI
server.tsx # Better Auth server instance (.tsx for email template support)
client.ts # React client hooks
src/app/api/auth/
[...all]/route.ts # API route handlersrc/lib/auth/
config.ts # Environment validation
schema.ts # Auto-generated by Better Auth CLI
server.tsx # Better Auth server instance (.tsx for email template support)
client.ts # React client hooks
src/app/api/auth/
[...all]/route.ts # API route handlerAdding Social Providers
To add OAuth providers like GitHub, Google, or Vercel, first add them as fields in your auth config:
import { loadConfig } from "../common/load-config";
export const authConfig = loadConfig({
server: {
secret: process.env.BETTER_AUTH_SECRET,
url: process.env.BETTER_AUTH_URL,
vercelClientId: { value: process.env.VERCEL_CLIENT_ID, optional: true },
vercelClientSecret: {
value: process.env.VERCEL_CLIENT_SECRET,
optional: true,
},
},
});import { loadConfig } from "../common/load-config";
export const authConfig = loadConfig({
server: {
secret: process.env.BETTER_AUTH_SECRET,
url: process.env.BETTER_AUTH_URL,
vercelClientId: { value: process.env.VERCEL_CLIENT_ID, optional: true },
vercelClientSecret: {
value: process.env.VERCEL_CLIENT_SECRET,
optional: true,
},
},
});Then configure them in the server:
export const auth = betterAuth({
// ...existing config
socialProviders: {
...(authConfig.server.vercelClientId &&
authConfig.server.vercelClientSecret && {
vercel: {
clientId: authConfig.server.vercelClientId,
clientSecret: authConfig.server.vercelClientSecret,
},
}),
},
});export const auth = betterAuth({
// ...existing config
socialProviders: {
...(authConfig.server.vercelClientId &&
authConfig.server.vercelClientSecret && {
vercel: {
clientId: authConfig.server.vercelClientId,
clientSecret: authConfig.server.vercelClientSecret,
},
}),
},
});Here we're doing it conditionally and treat Vercel Sign In as an optional feature.
Then use on the client:
await signIn.social({ provider: "vercel", callbackURL: "/chats" });await signIn.social({ provider: "vercel", callbackURL: "/chats" });References
Better Auth Emails
Add email verification, password reset, and account management emails to Better Auth using Resend.
Step 1: Create email templates
Create styled email templates for all auth flows.
Password Reset:
interface ForgotPasswordEmailProps {
resetLink: string;
userName?: string;
}
export function ForgotPasswordEmail({
resetLink,
userName,
}: ForgotPasswordEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#1a1a1a",
marginTop: "0",
marginBottom: "16px",
}}
>
Reset your password
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} we received a request to reset
your password. Click the button below to choose a new password.
</p>
<a
href={resetLink}
style={{
display: "inline-block",
backgroundColor: "#0d9488",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Reset Password
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't request a password reset, you can safely ignore
this email. Your password will remain unchanged.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#0d9488",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{resetLink}
</p>
</div>
</div>
);
}interface ForgotPasswordEmailProps {
resetLink: string;
userName?: string;
}
export function ForgotPasswordEmail({
resetLink,
userName,
}: ForgotPasswordEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#1a1a1a",
marginTop: "0",
marginBottom: "16px",
}}
>
Reset your password
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} we received a request to reset
your password. Click the button below to choose a new password.
</p>
<a
href={resetLink}
style={{
display: "inline-block",
backgroundColor: "#0d9488",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Reset Password
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't request a password reset, you can safely ignore
this email. Your password will remain unchanged.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#0d9488",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{resetLink}
</p>
</div>
</div>
);
}Email Verification:
interface VerifyEmailProps {
verificationLink: string;
userName?: string;
}
export function VerifyEmail({ verificationLink, userName }: VerifyEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#1a1a1a",
marginTop: "0",
marginBottom: "16px",
}}
>
Verify your email address
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} thanks for signing up! Please
verify your email address to complete your registration and access all
features.
</p>
<a
href={verificationLink}
style={{
display: "inline-block",
backgroundColor: "#0d9488",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Verify Email Address
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't create an account, you can safely ignore this
email.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#0d9488",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{verificationLink}
</p>
</div>
</div>
);
}interface VerifyEmailProps {
verificationLink: string;
userName?: string;
}
export function VerifyEmail({ verificationLink, userName }: VerifyEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#1a1a1a",
marginTop: "0",
marginBottom: "16px",
}}
>
Verify your email address
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} thanks for signing up! Please
verify your email address to complete your registration and access all
features.
</p>
<a
href={verificationLink}
style={{
display: "inline-block",
backgroundColor: "#0d9488",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Verify Email Address
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't create an account, you can safely ignore this
email.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#0d9488",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{verificationLink}
</p>
</div>
</div>
);
}Change Email Confirmation:
interface ChangeEmailProps {
confirmationLink: string;
newEmail: string;
userName?: string;
}
export function ChangeEmail({
confirmationLink,
newEmail,
userName,
}: ChangeEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#1a1a1a",
marginTop: "0",
marginBottom: "16px",
}}
>
Approve email change
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "16px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} we received a request to change
your email address.
</p>
<div
style={{
backgroundColor: "#f5f5f5",
borderRadius: "6px",
padding: "16px",
marginBottom: "24px",
}}
>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
margin: "0 0 4px 0",
}}
>
New email address:
</p>
<p
style={{
fontSize: "16px",
color: "#1a1a1a",
fontWeight: "500",
margin: "0",
}}
>
{newEmail}
</p>
</div>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
Click the button below to approve this change. A verification email
will then be sent to your new email address.
</p>
<a
href={confirmationLink}
style={{
display: "inline-block",
backgroundColor: "#0d9488",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Approve Email Change
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't request this change, please ignore this email or
contact support if you're concerned about your account security.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#0d9488",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{confirmationLink}
</p>
</div>
</div>
);
}interface ChangeEmailProps {
confirmationLink: string;
newEmail: string;
userName?: string;
}
export function ChangeEmail({
confirmationLink,
newEmail,
userName,
}: ChangeEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#1a1a1a",
marginTop: "0",
marginBottom: "16px",
}}
>
Approve email change
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "16px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} we received a request to change
your email address.
</p>
<div
style={{
backgroundColor: "#f5f5f5",
borderRadius: "6px",
padding: "16px",
marginBottom: "24px",
}}
>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
margin: "0 0 4px 0",
}}
>
New email address:
</p>
<p
style={{
fontSize: "16px",
color: "#1a1a1a",
fontWeight: "500",
margin: "0",
}}
>
{newEmail}
</p>
</div>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
Click the button below to approve this change. A verification email
will then be sent to your new email address.
</p>
<a
href={confirmationLink}
style={{
display: "inline-block",
backgroundColor: "#0d9488",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Approve Email Change
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't request this change, please ignore this email or
contact support if you're concerned about your account security.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#0d9488",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{confirmationLink}
</p>
</div>
</div>
);
}Delete Account Verification:
interface DeleteAccountEmailProps {
confirmationLink: string;
userName?: string;
}
export function DeleteAccountEmail({
confirmationLink,
userName,
}: DeleteAccountEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#dc2626",
marginTop: "0",
marginBottom: "16px",
}}
>
Confirm Account Deletion
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "16px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} we received a request to
permanently delete your account.
</p>
<div
style={{
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: "6px",
padding: "16px",
marginBottom: "24px",
}}
>
<p
style={{
fontSize: "14px",
color: "#dc2626",
fontWeight: "500",
margin: "0 0 8px 0",
}}
>
Warning: This action is irreversible
</p>
<p
style={{
fontSize: "14px",
color: "#7f1d1d",
margin: "0",
}}
>
Clicking the button below will permanently delete your account and
all associated data. This cannot be undone.
</p>
</div>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
If you're sure you want to proceed, click the button below:
</p>
<a
href={confirmationLink}
style={{
display: "inline-block",
backgroundColor: "#dc2626",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Delete My Account
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't request this deletion, please ignore this email or
contact support immediately. Your account will remain safe.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#dc2626",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{confirmationLink}
</p>
</div>
</div>
);
}interface DeleteAccountEmailProps {
confirmationLink: string;
userName?: string;
}
export function DeleteAccountEmail({
confirmationLink,
userName,
}: DeleteAccountEmailProps) {
return (
<div
style={{
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
maxWidth: "600px",
margin: "0 auto",
padding: "40px 20px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.06)",
}}
>
<h1
style={{
fontSize: "24px",
fontWeight: "600",
color: "#dc2626",
marginTop: "0",
marginBottom: "16px",
}}
>
Confirm Account Deletion
</h1>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "16px",
}}
>
{userName ? `Hi ${userName},` : "Hi,"} we received a request to
permanently delete your account.
</p>
<div
style={{
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: "6px",
padding: "16px",
marginBottom: "24px",
}}
>
<p
style={{
fontSize: "14px",
color: "#dc2626",
fontWeight: "500",
margin: "0 0 8px 0",
}}
>
Warning: This action is irreversible
</p>
<p
style={{
fontSize: "14px",
color: "#7f1d1d",
margin: "0",
}}
>
Clicking the button below will permanently delete your account and
all associated data. This cannot be undone.
</p>
</div>
<p
style={{
fontSize: "16px",
color: "#4a4a4a",
lineHeight: "1.6",
marginBottom: "24px",
}}
>
If you're sure you want to proceed, click the button below:
</p>
<a
href={confirmationLink}
style={{
display: "inline-block",
backgroundColor: "#dc2626",
color: "#ffffff",
padding: "14px 28px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "500",
fontSize: "16px",
}}
>
Delete My Account
</a>
<p
style={{
fontSize: "14px",
color: "#6b6b6b",
marginTop: "32px",
lineHeight: "1.5",
}}
>
If you didn't request this deletion, please ignore this email or
contact support immediately. Your account will remain safe.
</p>
<hr
style={{
border: "none",
borderTop: "1px solid #e5e5e5",
margin: "32px 0",
}}
/>
<p
style={{
fontSize: "12px",
color: "#9a9a9a",
margin: "0",
}}
>
This link will expire in 1 hour. If the button above doesn't
work, copy and paste this URL into your browser:
</p>
<p
style={{
fontSize: "12px",
color: "#dc2626",
wordBreak: "break-all",
marginTop: "8px",
}}
>
{confirmationLink}
</p>
</div>
</div>
);
}Step 2: Update the auth server
Update the auth server with email verification, change email, and delete account support:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/client";
import { authConfig } from "./config";
import { sendEmail } from "../resend/send";
import { ForgotPasswordEmail } from "./emails/forgot-password";
import { VerifyEmail } from "./emails/verify-email";
import { ChangeEmail } from "./emails/change-email";
import { DeleteAccountEmail } from "./emails/delete-account";
export const auth = betterAuth({
secret: authConfig.server.secret,
baseURL: authConfig.server.url,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
async sendResetPassword({ user, url }) {
void sendEmail({
to: user.email,
subject: "Reset Your Password",
react: <ForgotPasswordEmail resetLink={url} userName={user.name} />,
});
},
},
emailVerification: {
sendOnSignUp: true,
async sendVerificationEmail({ user, url }) {
void sendEmail({
to: user.email,
subject: "Verify your email address",
react: <VerifyEmail verificationLink={url} userName={user.name} />,
});
},
},
user: {
changeEmail: {
enabled: true,
async sendChangeEmailConfirmation({ user, newEmail, url }) {
void sendEmail({
to: user.email,
subject: "Approve email change",
react: (
<ChangeEmail
confirmationLink={url}
newEmail={newEmail}
userName={user.name}
/>
),
});
},
},
deleteUser: {
enabled: true,
async sendDeleteAccountVerification({ user, url }) {
void sendEmail({
to: user.email,
subject: "Confirm account deletion",
react: (
<DeleteAccountEmail confirmationLink={url} userName={user.name} />
),
});
},
},
},
});import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/client";
import { authConfig } from "./config";
import { sendEmail } from "../resend/send";
import { ForgotPasswordEmail } from "./emails/forgot-password";
import { VerifyEmail } from "./emails/verify-email";
import { ChangeEmail } from "./emails/change-email";
import { DeleteAccountEmail } from "./emails/delete-account";
export const auth = betterAuth({
secret: authConfig.server.secret,
baseURL: authConfig.server.url,
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
async sendResetPassword({ user, url }) {
void sendEmail({
to: user.email,
subject: "Reset Your Password",
react: <ForgotPasswordEmail resetLink={url} userName={user.name} />,
});
},
},
emailVerification: {
sendOnSignUp: true,
async sendVerificationEmail({ user, url }) {
void sendEmail({
to: user.email,
subject: "Verify your email address",
react: <VerifyEmail verificationLink={url} userName={user.name} />,
});
},
},
user: {
changeEmail: {
enabled: true,
async sendChangeEmailConfirmation({ user, newEmail, url }) {
void sendEmail({
to: user.email,
subject: "Approve email change",
react: (
<ChangeEmail
confirmationLink={url}
newEmail={newEmail}
userName={user.name}
/>
),
});
},
},
deleteUser: {
enabled: true,
async sendDeleteAccountVerification({ user, url }) {
void sendEmail({
to: user.email,
subject: "Confirm account deletion",
react: (
<DeleteAccountEmail confirmationLink={url} userName={user.name} />
),
});
},
},
},
});Usage
Request Password Reset
import { authClient } from "@/lib/auth/client";
await authClient.requestPasswordReset({
email: "user@example.com",
redirectTo: "/reset-password",
});import { authClient } from "@/lib/auth/client";
await authClient.requestPasswordReset({
email: "user@example.com",
redirectTo: "/reset-password",
});Reset Password
import { authClient } from "@/lib/auth/client";
await authClient.resetPassword({
newPassword: "newSecurePassword",
token: "token-from-url",
});import { authClient } from "@/lib/auth/client";
await authClient.resetPassword({
newPassword: "newSecurePassword",
token: "token-from-url",
});Send Verification Email
import { authClient } from "@/lib/auth/client";
await authClient.sendVerificationEmail({
email: "user@example.com",
callbackURL: "/chats",
});import { authClient } from "@/lib/auth/client";
await authClient.sendVerificationEmail({
email: "user@example.com",
callbackURL: "/chats",
});Change Email
import { authClient } from "@/lib/auth/client";
await authClient.changeEmail({
newEmail: "newemail@example.com",
callbackURL: "/profile",
});import { authClient } from "@/lib/auth/client";
await authClient.changeEmail({
newEmail: "newemail@example.com",
callbackURL: "/profile",
});Change Password
import { authClient } from "@/lib/auth/client";
await authClient.changePassword({
currentPassword: "oldPassword",
newPassword: "newPassword",
revokeOtherSessions: true,
});import { authClient } from "@/lib/auth/client";
await authClient.changePassword({
currentPassword: "oldPassword",
newPassword: "newPassword",
revokeOtherSessions: true,
});Delete Account
import { authClient } from "@/lib/auth/client";
await authClient.deleteUser({
password: "password",
callbackURL: "/",
});import { authClient } from "@/lib/auth/client";
await authClient.deleteUser({
password: "password",
callbackURL: "/",
});File Structure
src/lib/auth/
server.tsx # Already .tsx from base setup
emails/
forgot-password.tsx
verify-email.tsx
change-email.tsx
delete-account.tsxsrc/lib/auth/
server.tsx # Already .tsx from base setup
emails/
forgot-password.tsx
verify-email.tsx
change-email.tsx
delete-account.tsxBetter Auth Components
Add UI components and pages for authentication flows including sign in, sign up, forgot password, reset password, and email verification.
Add Toaster to layout
Update your layout to include the toast notification provider:
import { Toaster } from "@/components/ui/sonner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Toaster richColors position="top-center" />
</body>
</html>
);
}import { Toaster } from "@/components/ui/sonner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Toaster richColors position="top-center" />
</body>
</html>
);
}UI Components
Sign In Component
This component handles email/password sign-in with password visibility toggle and input icons. When requireEmailVerification is enabled (see Better Auth Emails), it includes an inline resend verification option.
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
import {
Loader2,
Mail,
Lock,
Eye,
EyeOff,
AlertCircle,
Send,
} from "lucide-react";
import { signIn, authClient } from "@/lib/auth/client";
import Link from "next/link";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
export function SignIn() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const router = useRouter();
const handleResendVerification = async () => {
if (!email) {
toast.error("Please enter your email address");
return;
}
setResendLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email,
callbackURL: "/sign-in",
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Verification email sent! Please check your inbox.");
} finally {
setResendLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setEmailNotVerified(false);
if (!email || !password) {
toast.error("Please fill in all fields");
return;
}
await signIn.email(
{
email,
password,
rememberMe,
callbackURL: "/chats",
},
{
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => {
const errorMessage = ctx.error.message.toLowerCase();
if (
errorMessage.includes("email not verified") ||
errorMessage.includes("verify your email")
) {
setEmailNotVerified(true);
} else {
toast.error(ctx.error.message);
}
},
onSuccess: () => router.push("/chats"),
},
);
};
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{emailNotVerified && (
<div className="rounded-lg border border-amber-600/30 bg-amber-950/50 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex flex-col gap-2">
<p className="font-medium text-amber-200">Email not verified</p>
<p className="text-sm text-amber-200/70">
Please verify your email address before signing in. Check your
inbox for a verification link.
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="w-fit text-amber-400 hover:text-amber-300 hover:bg-amber-500/10 px-0"
onClick={handleResendVerification}
disabled={resendLoading}
>
{resendLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
Resend verification email
</Button>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
className="pl-10 pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked === true)}
/>
<Label htmlFor="remember" className="text-sm font-normal">
Remember me for 30 days
</Label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="size-4 animate-spin" /> : "Sign in"}
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link
href="/sign-up"
className="text-primary font-medium hover:underline"
>
Create account
</Link>
</p>
</CardFooter>
</Card>
);
}"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
import {
Loader2,
Mail,
Lock,
Eye,
EyeOff,
AlertCircle,
Send,
} from "lucide-react";
import { signIn, authClient } from "@/lib/auth/client";
import Link from "next/link";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
export function SignIn() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const router = useRouter();
const handleResendVerification = async () => {
if (!email) {
toast.error("Please enter your email address");
return;
}
setResendLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email,
callbackURL: "/sign-in",
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Verification email sent! Please check your inbox.");
} finally {
setResendLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setEmailNotVerified(false);
if (!email || !password) {
toast.error("Please fill in all fields");
return;
}
await signIn.email(
{
email,
password,
rememberMe,
callbackURL: "/chats",
},
{
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => {
const errorMessage = ctx.error.message.toLowerCase();
if (
errorMessage.includes("email not verified") ||
errorMessage.includes("verify your email")
) {
setEmailNotVerified(true);
} else {
toast.error(ctx.error.message);
}
},
onSuccess: () => router.push("/chats"),
},
);
};
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{emailNotVerified && (
<div className="rounded-lg border border-amber-600/30 bg-amber-950/50 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex flex-col gap-2">
<p className="font-medium text-amber-200">Email not verified</p>
<p className="text-sm text-amber-200/70">
Please verify your email address before signing in. Check your
inbox for a verification link.
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="w-fit text-amber-400 hover:text-amber-300 hover:bg-amber-500/10 px-0"
onClick={handleResendVerification}
disabled={resendLoading}
>
{resendLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
Resend verification email
</Button>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
className="pl-10 pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked === true)}
/>
<Label htmlFor="remember" className="text-sm font-normal">
Remember me for 30 days
</Label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <Loader2 className="size-4 animate-spin" /> : "Sign in"}
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link
href="/sign-up"
className="text-primary font-medium hover:underline"
>
Create account
</Link>
</p>
</CardFooter>
</Card>
);
}Sign Up Component
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import Image from "next/image";
import {
Loader2,
X,
Mail,
Lock,
User,
Eye,
EyeOff,
Upload,
} from "lucide-react";
import { signUp } from "@/lib/auth/client";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import Link from "next/link";
export function SignUp() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!firstName || !lastName || !email || !password) {
toast.error("Please fill in all required fields");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
if (password !== passwordConfirmation) {
toast.error("Passwords do not match");
return;
}
await signUp.email(
{
email,
password,
name: `${firstName} ${lastName}`.trim(),
image: image ? await convertImageToBase64(image) : undefined,
},
{
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => {
toast.error(ctx.error.message);
},
onSuccess: () => {
setSuccess(true);
toast.success("Account created! Please check your email to verify.");
},
},
);
};
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<Mail className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Check your email</CardTitle>
<CardDescription className="text-base">
We've sent a verification link to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p>
Click the link in your email to verify your account and start using
the app.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Button
variant="outline"
className="w-full"
onClick={() => router.push("/sign-in")}
>
Back to sign in
</Button>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Create an account</CardTitle>
<CardDescription>Enter your details to get started</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first-name">First name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="first-name"
placeholder="John"
className="pl-10"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="last-name">Last name</Label>
<Input
id="last-name"
placeholder="Doe"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
className="pl-10 pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm your password"
className="pl-10 pr-10"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">Profile photo (optional)</Label>
<div className="flex items-center gap-4">
{imagePreview ? (
<div className="relative size-16 shrink-0 rounded-full overflow-hidden ring-2 ring-border">
<Image
src={imagePreview}
alt="Profile preview"
fill
className="object-cover"
/>
</div>
) : (
<div className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted">
<Upload className="size-6 text-muted-foreground" />
</div>
)}
<div className="flex flex-1 items-center gap-2">
<Input
id="image"
type="file"
accept="image/*"
onChange={handleImageChange}
className="flex-1"
/>
{imagePreview && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
>
<X className="size-4" />
</Button>
)}
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Create account"
)}
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/sign-in"
className="text-primary font-medium hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</Card>
);
}
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import Image from "next/image";
import {
Loader2,
X,
Mail,
Lock,
User,
Eye,
EyeOff,
Upload,
} from "lucide-react";
import { signUp } from "@/lib/auth/client";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import Link from "next/link";
export function SignUp() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!firstName || !lastName || !email || !password) {
toast.error("Please fill in all required fields");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
if (password !== passwordConfirmation) {
toast.error("Passwords do not match");
return;
}
await signUp.email(
{
email,
password,
name: `${firstName} ${lastName}`.trim(),
image: image ? await convertImageToBase64(image) : undefined,
},
{
onRequest: () => setLoading(true),
onResponse: () => setLoading(false),
onError: (ctx) => {
toast.error(ctx.error.message);
},
onSuccess: () => {
setSuccess(true);
toast.success("Account created! Please check your email to verify.");
},
},
);
};
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<Mail className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Check your email</CardTitle>
<CardDescription className="text-base">
We've sent a verification link to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p>
Click the link in your email to verify your account and start using
the app.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Button
variant="outline"
className="w-full"
onClick={() => router.push("/sign-in")}
>
Back to sign in
</Button>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Create an account</CardTitle>
<CardDescription>Enter your details to get started</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first-name">First name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="first-name"
placeholder="John"
className="pl-10"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="last-name">Last name</Label>
<Input
id="last-name"
placeholder="Doe"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
className="pl-10 pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm your password"
className="pl-10 pr-10"
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="image">Profile photo (optional)</Label>
<div className="flex items-center gap-4">
{imagePreview ? (
<div className="relative size-16 shrink-0 rounded-full overflow-hidden ring-2 ring-border">
<Image
src={imagePreview}
alt="Profile preview"
fill
className="object-cover"
/>
</div>
) : (
<div className="flex size-16 shrink-0 items-center justify-center rounded-full bg-muted">
<Upload className="size-6 text-muted-foreground" />
</div>
)}
<div className="flex flex-1 items-center gap-2">
<Input
id="image"
type="file"
accept="image/*"
onChange={handleImageChange}
className="flex-1"
/>
{imagePreview && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
>
<X className="size-4" />
</Button>
)}
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Create account"
)}
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<p className="text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/sign-in"
className="text-primary font-medium hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</Card>
);
}
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}Forgot Password Component
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { Loader2, Mail, ArrowLeft, CheckCircle } from "lucide-react";
import { authClient } from "@/lib/auth/client";
import { toast } from "sonner";
import Link from "next/link";
export function ForgotPassword() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error("Please enter your email address");
return;
}
setLoading(true);
try {
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (error) {
toast.error(error.message);
return;
}
setSuccess(true);
} finally {
setLoading(false);
}
};
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Check your email</CardTitle>
<CardDescription className="text-base">
If an account exists for <strong>{email}</strong>, we've sent
password reset instructions.
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p>
Didn't receive the email? Check your spam folder or try again
with a different email address.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Button
variant="outline"
className="w-full"
onClick={() => setSuccess(false)}
>
Try another email
</Button>
<Link href="/sign-in" className="w-full">
<Button variant="ghost" className="w-full">
<ArrowLeft className="size-4" />
Back to sign in
</Button>
</Link>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Forgot password?</CardTitle>
<CardDescription>
Enter your email and we'll send you a link to reset your password
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Send reset link"
)}
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/sign-in" className="w-full">
<Button variant="ghost" className="w-full">
<ArrowLeft className="size-4" />
Back to sign in
</Button>
</Link>
</CardFooter>
</Card>
);
}"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { Loader2, Mail, ArrowLeft, CheckCircle } from "lucide-react";
import { authClient } from "@/lib/auth/client";
import { toast } from "sonner";
import Link from "next/link";
export function ForgotPassword() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error("Please enter your email address");
return;
}
setLoading(true);
try {
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (error) {
toast.error(error.message);
return;
}
setSuccess(true);
} finally {
setLoading(false);
}
};
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Check your email</CardTitle>
<CardDescription className="text-base">
If an account exists for <strong>{email}</strong>, we've sent
password reset instructions.
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p>
Didn't receive the email? Check your spam folder or try again
with a different email address.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Button
variant="outline"
className="w-full"
onClick={() => setSuccess(false)}
>
Try another email
</Button>
<Link href="/sign-in" className="w-full">
<Button variant="ghost" className="w-full">
<ArrowLeft className="size-4" />
Back to sign in
</Button>
</Link>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Forgot password?</CardTitle>
<CardDescription>
Enter your email and we'll send you a link to reset your password
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
className="pl-10"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Send reset link"
)}
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/sign-in" className="w-full">
<Button variant="ghost" className="w-full">
<ArrowLeft className="size-4" />
Back to sign in
</Button>
</Link>
</CardFooter>
</Card>
);
}Reset Password Component
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { Loader2, Lock, Eye, EyeOff, CheckCircle, XCircle } from "lucide-react";
import { authClient } from "@/lib/auth/client";
import { toast } from "sonner";
import Link from "next/link";
import { useRouter } from "next/navigation";
type ResetPasswordProps = {
token: string | null;
error: string | null;
};
export function ResetPassword({ token, error }: ResetPasswordProps) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
if (error || !token) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold">Invalid link</CardTitle>
<CardDescription className="text-base">
{error === "INVALID_TOKEN"
? "This password reset link is invalid or has expired."
: "Something went wrong. Please try again."}
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/forgot-password" className="w-full">
<Button className="w-full">Request new link</Button>
</Link>
<Link href="/sign-in" className="w-full">
<Button variant="ghost" className="w-full">
Back to sign in
</Button>
</Link>
</CardFooter>
</Card>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
try {
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message);
return;
}
setSuccess(true);
toast.success("Password reset successfully!");
} finally {
setLoading(false);
}
};
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Password reset!</CardTitle>
<CardDescription className="text-base">
Your password has been successfully reset. You can now sign in with
your new password.
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Button className="w-full" onClick={() => router.push("/sign-in")}>
Sign in
</Button>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Set new password</CardTitle>
<CardDescription>Enter your new password below</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
className="pl-10 pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm your new password"
className="pl-10 pr-10"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Reset password"
)}
</Button>
</form>
</CardContent>
</Card>
);
}"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { Loader2, Lock, Eye, EyeOff, CheckCircle, XCircle } from "lucide-react";
import { authClient } from "@/lib/auth/client";
import { toast } from "sonner";
import Link from "next/link";
import { useRouter } from "next/navigation";
type ResetPasswordProps = {
token: string | null;
error: string | null;
};
export function ResetPassword({ token, error }: ResetPasswordProps) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
if (error || !token) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold">Invalid link</CardTitle>
<CardDescription className="text-base">
{error === "INVALID_TOKEN"
? "This password reset link is invalid or has expired."
: "Something went wrong. Please try again."}
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/forgot-password" className="w-full">
<Button className="w-full">Request new link</Button>
</Link>
<Link href="/sign-in" className="w-full">
<Button variant="ghost" className="w-full">
Back to sign in
</Button>
</Link>
</CardFooter>
</Card>
);
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
setLoading(true);
try {
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message);
return;
}
setSuccess(true);
toast.success("Password reset successfully!");
} finally {
setLoading(false);
}
};
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Password reset!</CardTitle>
<CardDescription className="text-base">
Your password has been successfully reset. You can now sign in with
your new password.
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Button className="w-full" onClick={() => router.push("/sign-in")}>
Sign in
</Button>
</CardFooter>
</Card>
);
}
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Set new password</CardTitle>
<CardDescription>Enter your new password below</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="At least 8 characters"
className="pl-10 pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showConfirmPassword ? "text" : "password"}
placeholder="Confirm your new password"
className="pl-10 pr-10"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Reset password"
)}
</Button>
</form>
</CardContent>
</Card>
);
}Verify Email Result Component
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CheckCircle, XCircle, Mail } from "lucide-react";
import Link from "next/link";
type VerifyEmailResultProps = {
success: boolean;
error?: string;
};
export function VerifyEmailResult({ success, error }: VerifyEmailResultProps) {
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Email verified!</CardTitle>
<CardDescription className="text-base">
Your email address has been successfully verified. You can now sign
in to your account.
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/sign-in" className="w-full">
<Button className="w-full">Sign in</Button>
</Link>
</CardFooter>
</Card>
);
}
const errorMessage = (() => {
switch (error) {
case "INVALID_TOKEN":
return "This verification link is invalid or has expired.";
case "NO_TOKEN":
return "No verification token was provided.";
case "VERIFICATION_FAILED":
return "Email verification failed. Please try again.";
default:
return "Something went wrong during verification.";
}
})();
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold">
Verification failed
</CardTitle>
<CardDescription className="text-base">{errorMessage}</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p>
If your link has expired, you can request a new verification email
after signing in.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/sign-in" className="w-full">
<Button className="w-full">
<Mail className="size-4" />
Sign in to resend
</Button>
</Link>
<Link href="/" className="w-full">
<Button variant="ghost" className="w-full">
Back to home
</Button>
</Link>
</CardFooter>
</Card>
);
}"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CheckCircle, XCircle, Mail } from "lucide-react";
import Link from "next/link";
type VerifyEmailResultProps = {
success: boolean;
error?: string;
};
export function VerifyEmailResult({ success, error }: VerifyEmailResultProps) {
if (success) {
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-primary/10">
<CheckCircle className="size-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">Email verified!</CardTitle>
<CardDescription className="text-base">
Your email address has been successfully verified. You can now sign
in to your account.
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/sign-in" className="w-full">
<Button className="w-full">Sign in</Button>
</Link>
</CardFooter>
</Card>
);
}
const errorMessage = (() => {
switch (error) {
case "INVALID_TOKEN":
return "This verification link is invalid or has expired.";
case "NO_TOKEN":
return "No verification token was provided.";
case "VERIFICATION_FAILED":
return "Email verification failed. Please try again.";
default:
return "Something went wrong during verification.";
}
})();
return (
<Card className="w-full max-w-md shadow-lg">
<CardHeader className="space-y-1 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-destructive/10">
<XCircle className="size-8 text-destructive" />
</div>
<CardTitle className="text-2xl font-bold">
Verification failed
</CardTitle>
<CardDescription className="text-base">{errorMessage}</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-muted-foreground">
<p>
If your link has expired, you can request a new verification email
after signing in.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4 border-t pt-6">
<Link href="/sign-in" className="w-full">
<Button className="w-full">
<Mail className="size-4" />
Sign in to resend
</Button>
</Link>
<Link href="/" className="w-full">
<Button variant="ghost" className="w-full">
Back to home
</Button>
</Link>
</CardFooter>
</Card>
);
}Auth Pages
Create pages for all auth flows with server-side session checks.
Sign In Page
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SignIn } from "@/components/auth/sign-in";
export const metadata: Metadata = {
title: "Sign In",
description: "Sign in to your account.",
};
export default async function SignInPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<SignIn />
</main>
);
}import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SignIn } from "@/components/auth/sign-in";
export const metadata: Metadata = {
title: "Sign In",
description: "Sign in to your account.",
};
export default async function SignInPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<SignIn />
</main>
);
}Sign Up Page
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SignUp } from "@/components/auth/sign-up";
export const metadata: Metadata = {
title: "Create Account",
description: "Create a free account to get started.",
};
export default async function SignUpPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<SignUp />
</main>
);
}import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SignUp } from "@/components/auth/sign-up";
export const metadata: Metadata = {
title: "Create Account",
description: "Create a free account to get started.",
};
export default async function SignUpPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<SignUp />
</main>
);
}Forgot Password Page
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { ForgotPassword } from "@/components/auth/forgot-password";
export const metadata: Metadata = {
title: "Forgot Password",
description:
"Reset your password by entering your email address. We'll send you a link to create a new one.",
};
export default async function ForgotPasswordPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<ForgotPassword />
</main>
);
}import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { ForgotPassword } from "@/components/auth/forgot-password";
export const metadata: Metadata = {
title: "Forgot Password",
description:
"Reset your password by entering your email address. We'll send you a link to create a new one.",
};
export default async function ForgotPasswordPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<ForgotPassword />
</main>
);
}Reset Password Page
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { ResetPassword } from "@/components/auth/reset-password";
export const metadata: Metadata = {
title: "Reset Password",
description: "Create a new password for your account.",
};
type SearchParams = Promise<{ token?: string; error?: string }>;
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
const { token, error } = await searchParams;
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<ResetPassword token={token ?? null} error={error ?? null} />
</main>
);
}import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { ResetPassword } from "@/components/auth/reset-password";
export const metadata: Metadata = {
title: "Reset Password",
description: "Create a new password for your account.",
};
type SearchParams = Promise<{ token?: string; error?: string }>;
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats");
}
const { token, error } = await searchParams;
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<ResetPassword token={token ?? null} error={error ?? null} />
</main>
);
}Verify Email Page
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { VerifyEmailResult } from "@/components/auth/verify-email-result";
export const metadata: Metadata = {
title: "Verify Email",
description: "Confirm your email address to complete your account setup.",
};
type SearchParams = Promise<{ token?: string; error?: string }>;
export default async function VerifyEmailPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { token, error } = await searchParams;
let verificationResult: { success: boolean; error?: string } = {
success: false,
};
if (error) {
verificationResult = { success: false, error };
} else if (token) {
try {
const result = await auth.api.verifyEmail({
query: { token },
headers: await headers(),
});
verificationResult = { success: result?.status === true };
} catch {
verificationResult = { success: false, error: "VERIFICATION_FAILED" };
}
} else {
verificationResult = { success: false, error: "NO_TOKEN" };
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<VerifyEmailResult
success={verificationResult.success}
error={verificationResult.error}
/>
</main>
);
}import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { VerifyEmailResult } from "@/components/auth/verify-email-result";
export const metadata: Metadata = {
title: "Verify Email",
description: "Confirm your email address to complete your account setup.",
};
type SearchParams = Promise<{ token?: string; error?: string }>;
export default async function VerifyEmailPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { token, error } = await searchParams;
let verificationResult: { success: boolean; error?: string } = {
success: false,
};
if (error) {
verificationResult = { success: false, error };
} else if (token) {
try {
const result = await auth.api.verifyEmail({
query: { token },
headers: await headers(),
});
verificationResult = { success: result?.status === true };
} catch {
verificationResult = { success: false, error: "VERIFICATION_FAILED" };
}
} else {
verificationResult = { success: false, error: "NO_TOKEN" };
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<VerifyEmailResult
success={verificationResult.success}
error={verificationResult.error}
/>
</main>
);
}Extending with OAuth Providers
To add social sign-in buttons (like GitHub, Google, or Vercel), you can extend the SignIn and SignUp components with a showSocialSignIn prop:
// Extended SignIn with optional OAuth
export function SignIn({
showVercelSignIn = false,
}: {
showVercelSignIn?: boolean;
} = {}) {
// ... existing state
const handleVercelSignIn = async () => {
await signIn.social(
{ provider: "vercel", callbackURL: "/chats" },
{
onRequest: () => setSocialLoading(true),
onResponse: () => setSocialLoading(false),
onError: (ctx) => toast.error(ctx.error.message),
},
);
};
return (
<Card>
{/* Add OAuth button before the form */}
{showVercelSignIn && (
<>
<Button variant="outline" onClick={handleVercelSignIn}>
Sign in with Vercel
</Button>
<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-card px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
</>
)}
{/* Rest of the form */}
</Card>
);
}// Extended SignIn with optional OAuth
export function SignIn({
showVercelSignIn = false,
}: {
showVercelSignIn?: boolean;
} = {}) {
// ... existing state
const handleVercelSignIn = async () => {
await signIn.social(
{ provider: "vercel", callbackURL: "/chats" },
{
onRequest: () => setSocialLoading(true),
onResponse: () => setSocialLoading(false),
onError: (ctx) => toast.error(ctx.error.message),
},
);
};
return (
<Card>
{/* Add OAuth button before the form */}
{showVercelSignIn && (
<>
<Button variant="outline" onClick={handleVercelSignIn}>
Sign in with Vercel
</Button>
<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-card px-2 text-muted-foreground">
Or continue with email
</span>
</div>
</div>
</>
)}
{/* Rest of the form */}
</Card>
);
}Use with a feature flag to conditionally show OAuth based on environment configuration:
import { vercelSignInFlag } from "@/lib/auth/flags";
export default async function SignInPage() {
const showVercelSignIn = await vercelSignInFlag();
return <SignIn showVercelSignIn={showVercelSignIn} />;
}import { vercelSignInFlag } from "@/lib/auth/flags";
export default async function SignInPage() {
const showVercelSignIn = await vercelSignInFlag();
return <SignIn showVercelSignIn={showVercelSignIn} />;
}See the Feature Flags recipe for setting up the Flags SDK.
File Structure
src/components/auth/
sign-in.tsx
sign-up.tsx
forgot-password.tsx
reset-password.tsx
verify-email-result.tsx
src/app/
sign-in/page.tsx
sign-up/page.tsx
forgot-password/page.tsx
reset-password/page.tsx
verify-email/page.tsxsrc/components/auth/
sign-in.tsx
sign-up.tsx
forgot-password.tsx
reset-password.tsx
verify-email-result.tsx
src/app/
sign-in/page.tsx
sign-up/page.tsx
forgot-password/page.tsx
reset-password/page.tsx
verify-email/page.tsxBetter Auth Profile & Account
Add a complete account settings page with profile editing, password changes, email updates, session management, and account deletion.
User Menu Component
Create a dropdown menu that shows authentication state and user options:
"use client";
import { useSession, signOut } from "@/lib/auth/client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { Settings, LogOut, MessageSquare, AlertCircle } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
export function UserMenu() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) {
return <Skeleton className="size-9 rounded-full" />;
}
if (!session) {
return (
<div className="flex items-center gap-2">
<Link href="/sign-in">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/sign-up">
<Button size="sm">Get started</Button>
</Link>
</div>
);
}
const user = session.user;
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
const handleSignOut = async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
toast.success("Signed out successfully");
router.push("/");
},
onError: () => {
toast.error("Failed to sign out");
},
},
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative rounded-full p-0"
>
<Avatar className="size-9">
<AvatarImage src={user.image || undefined} alt={user.name} />
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
{!user.emailVerified && (
<span className="absolute -top-0.5 -right-0.5 flex size-3">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex size-3 rounded-full bg-amber-500" />
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
{!user.emailVerified && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="/profile"
className="flex items-center text-amber-600 dark:text-amber-500"
>
<AlertCircle className="mr-2 size-4" />
Verify your email
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/chats">
<MessageSquare className="mr-2 size-4" />
Chats
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 size-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="text-destructive">
<LogOut className="mr-2 size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}"use client";
import { useSession, signOut } from "@/lib/auth/client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Skeleton } from "@/components/ui/skeleton";
import { Settings, LogOut, MessageSquare, AlertCircle } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
export function UserMenu() {
const { data: session, isPending } = useSession();
const router = useRouter();
if (isPending) {
return <Skeleton className="size-9 rounded-full" />;
}
if (!session) {
return (
<div className="flex items-center gap-2">
<Link href="/sign-in">
<Button variant="ghost" size="sm">
Sign in
</Button>
</Link>
<Link href="/sign-up">
<Button size="sm">Get started</Button>
</Link>
</div>
);
}
const user = session.user;
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
const handleSignOut = async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
toast.success("Signed out successfully");
router.push("/");
},
onError: () => {
toast.error("Failed to sign out");
},
},
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative rounded-full p-0"
>
<Avatar className="size-9">
<AvatarImage src={user.image || undefined} alt={user.name} />
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
{!user.emailVerified && (
<span className="absolute -top-0.5 -right-0.5 flex size-3">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex size-3 rounded-full bg-amber-500" />
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
{!user.emailVerified && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href="/profile"
className="flex items-center text-amber-600 dark:text-amber-500"
>
<AlertCircle className="mr-2 size-4" />
Verify your email
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href="/chats">
<MessageSquare className="mr-2 size-4" />
Chats
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 size-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="text-destructive">
<LogOut className="mr-2 size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Key features:
- Shows sign in/sign up buttons when logged out
- Displays user avatar with initials fallback when logged in
- Animated badge indicator for unverified email
- Links to chats, settings, and sign out action
Profile Components
Profile Header
Displays user info with inline editing for name and profile image:
"use client";
import { useSession, authClient } from "@/lib/auth/client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { Loader2, Pencil, X, Check } from "lucide-react";
import { toast } from "sonner";
import Image from "next/image";
export function ProfileHeader() {
const { data: session, isPending } = useSession();
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState("");
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
if (isPending) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (!session) {
return null;
}
const user = session.user;
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleStartEditing = () => {
setName(user.name);
setImagePreview(user.image || null);
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setName("");
setImage(null);
setImagePreview(null);
};
const handleSave = async () => {
setLoading(true);
try {
let imageData: string | undefined;
if (image) {
imageData = await convertImageToBase64(image);
}
const { error } = await authClient.updateUser({
name: name || undefined,
image: imageData,
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Profile updated successfully");
setIsEditing(false);
setImage(null);
setImagePreview(null);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Profile</CardTitle>
<CardDescription>Your account information</CardDescription>
</div>
{!isEditing && (
<Button variant="outline" size="sm" onClick={handleStartEditing}>
<Pencil className="size-4" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isEditing ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="relative">
{imagePreview ? (
<div className="relative size-20 rounded-full overflow-hidden">
<Image
src={imagePreview}
alt="Profile preview"
fill
className="object-cover"
/>
</div>
) : (
<Avatar className="size-20">
<AvatarFallback className="text-lg">
{initials}
</AvatarFallback>
</Avatar>
)}
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="profile-image">Profile Image</Label>
<div className="flex items-center gap-2">
<Input
id="profile-image"
type="file"
accept="image/*"
onChange={handleImageChange}
/>
{imagePreview && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
>
<X className="size-4" />
</Button>
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
Save
</Button>
<Button
variant="outline"
onClick={handleCancel}
disabled={loading}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-4">
<Avatar className="size-20">
<AvatarImage src={user.image || undefined} alt={user.name} />
<AvatarFallback className="text-lg">{initials}</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-lg font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
<Badge variant={user.emailVerified ? "default" : "secondary"}>
{user.emailVerified ? "Verified" : "Unverified"}
</Badge>
</div>
</div>
)}
</CardContent>
</Card>
);
}
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}"use client";
import { useSession, authClient } from "@/lib/auth/client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { Loader2, Pencil, X, Check } from "lucide-react";
import { toast } from "sonner";
import Image from "next/image";
export function ProfileHeader() {
const { data: session, isPending } = useSession();
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState("");
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
if (isPending) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (!session) {
return null;
}
const user = session.user;
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImage(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleStartEditing = () => {
setName(user.name);
setImagePreview(user.image || null);
setIsEditing(true);
};
const handleCancel = () => {
setIsEditing(false);
setName("");
setImage(null);
setImagePreview(null);
};
const handleSave = async () => {
setLoading(true);
try {
let imageData: string | undefined;
if (image) {
imageData = await convertImageToBase64(image);
}
const { error } = await authClient.updateUser({
name: name || undefined,
image: imageData,
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Profile updated successfully");
setIsEditing(false);
setImage(null);
setImagePreview(null);
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Profile</CardTitle>
<CardDescription>Your account information</CardDescription>
</div>
{!isEditing && (
<Button variant="outline" size="sm" onClick={handleStartEditing}>
<Pencil className="size-4" />
Edit
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isEditing ? (
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="relative">
{imagePreview ? (
<div className="relative size-20 rounded-full overflow-hidden">
<Image
src={imagePreview}
alt="Profile preview"
fill
className="object-cover"
/>
</div>
) : (
<Avatar className="size-20">
<AvatarFallback className="text-lg">
{initials}
</AvatarFallback>
</Avatar>
)}
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="profile-image">Profile Image</Label>
<div className="flex items-center gap-2">
<Input
id="profile-image"
type="file"
accept="image/*"
onChange={handleImageChange}
/>
{imagePreview && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setImage(null);
setImagePreview(null);
}}
>
<X className="size-4" />
</Button>
)}
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
Save
</Button>
<Button
variant="outline"
onClick={handleCancel}
disabled={loading}
>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-4">
<Avatar className="size-20">
<AvatarImage src={user.image || undefined} alt={user.name} />
<AvatarFallback className="text-lg">{initials}</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-lg font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
<Badge variant={user.emailVerified ? "default" : "secondary"}>
{user.emailVerified ? "Verified" : "Unverified"}
</Badge>
</div>
</div>
)}
</CardContent>
</Card>
);
}
async function convertImageToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}Change Email
"use client";
import { useSession, authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
export function ChangeEmail() {
const { data: session } = useSession();
const [newEmail, setNewEmail] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEmail) {
toast.error("Please enter a new email address");
return;
}
if (newEmail === session?.user.email) {
toast.error("New email must be different from current email");
return;
}
setLoading(true);
try {
const { error } = await authClient.changeEmail({
newEmail,
callbackURL: "/profile",
});
if (error) {
toast.error(error.message);
return;
}
toast.success(
"A confirmation email has been sent to your current email address",
);
setNewEmail("");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Change Email</CardTitle>
<CardDescription>
Update your email address. You'll need to verify both your
current and new email.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-email">Current Email</Label>
<Input
id="current-email"
type="email"
value={session?.user.email || ""}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-email">New Email</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="new@example.com"
required
/>
</div>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Change Email
</Button>
</form>
</CardContent>
</Card>
);
}"use client";
import { useSession, authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
export function ChangeEmail() {
const { data: session } = useSession();
const [newEmail, setNewEmail] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEmail) {
toast.error("Please enter a new email address");
return;
}
if (newEmail === session?.user.email) {
toast.error("New email must be different from current email");
return;
}
setLoading(true);
try {
const { error } = await authClient.changeEmail({
newEmail,
callbackURL: "/profile",
});
if (error) {
toast.error(error.message);
return;
}
toast.success(
"A confirmation email has been sent to your current email address",
);
setNewEmail("");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Change Email</CardTitle>
<CardDescription>
Update your email address. You'll need to verify both your
current and new email.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-email">Current Email</Label>
<Input
id="current-email"
type="email"
value={session?.user.email || ""}
disabled
className="bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-email">New Email</Label>
<Input
id="new-email"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="new@example.com"
required
/>
</div>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Change Email
</Button>
</form>
</CardContent>
</Card>
);
}Change Password
"use client";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
export function ChangePassword() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error("New passwords do not match");
return;
}
if (newPassword.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions,
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="revoke-sessions"
checked={revokeOtherSessions}
onCheckedChange={(checked) =>
setRevokeOtherSessions(checked === true)
}
/>
<Label htmlFor="revoke-sessions" className="text-sm font-normal">
Sign out from all other devices
</Label>
</div>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Change Password
</Button>
</form>
</CardContent>
</Card>
);
}"use client";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
export function ChangePassword() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [revokeOtherSessions, setRevokeOtherSessions] = useState(true);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error("New passwords do not match");
return;
}
if (newPassword.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions,
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="revoke-sessions"
checked={revokeOtherSessions}
onCheckedChange={(checked) =>
setRevokeOtherSessions(checked === true)
}
/>
<Label htmlFor="revoke-sessions" className="text-sm font-normal">
Sign out from all other devices
</Label>
</div>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Change Password
</Button>
</form>
</CardContent>
</Card>
);
}Session Management
"use client";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Loader2, LogOut } from "lucide-react";
import { toast } from "sonner";
export function Sessions() {
const [loading, setLoading] = useState(false);
const handleRevokeOtherSessions = async () => {
setLoading(true);
try {
const { error } = await authClient.revokeOtherSessions();
if (error) {
toast.error(error.message);
return;
}
toast.success("All other sessions have been signed out");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>
Manage your active sessions across devices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
If you notice any suspicious activity or want to secure your account,
you can sign out from all other devices.
</p>
<Button
variant="outline"
onClick={handleRevokeOtherSessions}
disabled={loading}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LogOut className="size-4" />
)}
Sign out other devices
</Button>
</CardContent>
</Card>
);
}"use client";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Loader2, LogOut } from "lucide-react";
import { toast } from "sonner";
export function Sessions() {
const [loading, setLoading] = useState(false);
const handleRevokeOtherSessions = async () => {
setLoading(true);
try {
const { error } = await authClient.revokeOtherSessions();
if (error) {
toast.error(error.message);
return;
}
toast.success("All other sessions have been signed out");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>
Manage your active sessions across devices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
If you notice any suspicious activity or want to secure your account,
you can sign out from all other devices.
</p>
<Button
variant="outline"
onClick={handleRevokeOtherSessions}
disabled={loading}
>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LogOut className="size-4" />
)}
Sign out other devices
</Button>
</CardContent>
</Card>
);
}Resend Verification
Shows only when email is not verified:
"use client";
import { useSession, authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Loader2, Mail, CheckCircle } from "lucide-react";
import { toast } from "sonner";
export function ResendVerification() {
const { data: session } = useSession();
const [loading, setLoading] = useState(false);
if (!session || session.user.emailVerified) {
return null;
}
const handleResend = async () => {
setLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email: session.user.email,
callbackURL: "/profile",
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Verification email sent");
} finally {
setLoading(false);
}
};
return (
<Card className="border-amber-500/50 bg-amber-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="size-5 text-amber-600" />
Verify Your Email
</CardTitle>
<CardDescription>
Your email address has not been verified yet. Please check your inbox
for a verification link.
</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-4">
<Button onClick={handleResend} disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<CheckCircle className="size-4" />
)}
Resend Verification Email
</Button>
</CardContent>
</Card>
);
}"use client";
import { useSession, authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Loader2, Mail, CheckCircle } from "lucide-react";
import { toast } from "sonner";
export function ResendVerification() {
const { data: session } = useSession();
const [loading, setLoading] = useState(false);
if (!session || session.user.emailVerified) {
return null;
}
const handleResend = async () => {
setLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email: session.user.email,
callbackURL: "/profile",
});
if (error) {
toast.error(error.message);
return;
}
toast.success("Verification email sent");
} finally {
setLoading(false);
}
};
return (
<Card className="border-amber-500/50 bg-amber-500/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="size-5 text-amber-600" />
Verify Your Email
</CardTitle>
<CardDescription>
Your email address has not been verified yet. Please check your inbox
for a verification link.
</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-4">
<Button onClick={handleResend} disabled={loading}>
{loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<CheckCircle className="size-4" />
)}
Resend Verification Email
</Button>
</CardContent>
</Card>
);
}Delete Account
"use client";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useState } from "react";
import { Loader2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
export function DeleteAccount() {
const [password, setPassword] = useState("");
const [confirmText, setConfirmText] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const canDelete = confirmText === "DELETE";
const handleDelete = async () => {
if (!canDelete) return;
setLoading(true);
try {
const { error } = await authClient.deleteUser({
password,
callbackURL: "/",
});
if (error) {
toast.error(error.message);
return;
}
toast.success(
"A confirmation email has been sent to verify account deletion",
);
setOpen(false);
setPassword("");
setConfirmText("");
} finally {
setLoading(false);
}
};
return (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Delete Account</CardTitle>
<CardDescription>
Permanently delete your account and all associated data. This action
cannot be undone.
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<AlertTriangle className="size-4" />
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove all your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="delete-password">Enter your password</Label>
<Input
id="delete-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-delete">
Type <span className="font-mono font-bold">DELETE</span> to
confirm
</Label>
<Input
id="confirm-delete"
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE"
/>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPassword("");
setConfirmText("");
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={!canDelete || loading || !password}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Loader2 className="size-4 animate-spin" />}
Delete Account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}"use client";
import { authClient } from "@/lib/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useState } from "react";
import { Loader2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
export function DeleteAccount() {
const [password, setPassword] = useState("");
const [confirmText, setConfirmText] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const canDelete = confirmText === "DELETE";
const handleDelete = async () => {
if (!canDelete) return;
setLoading(true);
try {
const { error } = await authClient.deleteUser({
password,
callbackURL: "/",
});
if (error) {
toast.error(error.message);
return;
}
toast.success(
"A confirmation email has been sent to verify account deletion",
);
setOpen(false);
setPassword("");
setConfirmText("");
} finally {
setLoading(false);
}
};
return (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Delete Account</CardTitle>
<CardDescription>
Permanently delete your account and all associated data. This action
cannot be undone.
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<AlertTriangle className="size-4" />
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove all your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="delete-password">Enter your password</Label>
<Input
id="delete-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-delete">
Type <span className="font-mono font-bold">DELETE</span> to
confirm
</Label>
<Input
id="confirm-delete"
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE"
/>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPassword("");
setConfirmText("");
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={!canDelete || loading || !password}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Loader2 className="size-4 animate-spin" />}
Delete Account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}Profile Page
Create the account settings page that combines all profile components:
import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/server";
import { ProfileHeader } from "@/components/profile/profile-header";
import { ChangePassword } from "@/components/profile/change-password";
import { ChangeEmail } from "@/components/profile/change-email";
import { DeleteAccount } from "@/components/profile/delete-account";
import { Sessions } from "@/components/profile/sessions";
import { ResendVerification } from "@/components/profile/resend-verification";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export const metadata: Metadata = {
title: "Account Settings",
description:
"Manage your profile, security settings, email preferences, and active sessions.",
};
export default async function ProfilePage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return (
<div className="min-h-dvh bg-muted/30">
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center gap-4 max-w-2xl">
<Link href="/chats">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="size-4" />
</Button>
</Link>
<h1 className="font-semibold">Account Settings</h1>
</div>
</header>
<main className="container max-w-2xl py-8 px-4">
<div className="space-y-6">
<ResendVerification />
<ProfileHeader />
<ChangeEmail />
<ChangePassword />
<Sessions />
<DeleteAccount />
</div>
</main>
</div>
);
}import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/server";
import { ProfileHeader } from "@/components/profile/profile-header";
import { ChangePassword } from "@/components/profile/change-password";
import { ChangeEmail } from "@/components/profile/change-email";
import { DeleteAccount } from "@/components/profile/delete-account";
import { Sessions } from "@/components/profile/sessions";
import { ResendVerification } from "@/components/profile/resend-verification";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export const metadata: Metadata = {
title: "Account Settings",
description:
"Manage your profile, security settings, email preferences, and active sessions.",
};
export default async function ProfilePage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return (
<div className="min-h-dvh bg-muted/30">
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center gap-4 max-w-2xl">
<Link href="/chats">
<Button variant="ghost" size="icon-sm">
<ArrowLeft className="size-4" />
</Button>
</Link>
<h1 className="font-semibold">Account Settings</h1>
</div>
</header>
<main className="container max-w-2xl py-8 px-4">
<div className="space-y-6">
<ResendVerification />
<ProfileHeader />
<ChangeEmail />
<ChangePassword />
<Sessions />
<DeleteAccount />
</div>
</main>
</div>
);
}File Structure
src/components/auth/
user-menu.tsx # Dropdown with auth state
src/components/profile/
profile-header.tsx # User info with edit mode
change-email.tsx # Email change form
change-password.tsx # Password change form
sessions.tsx # Session management
resend-verification.tsx # Email verification reminder
delete-account.tsx # Account deletion with confirmation
src/app/
profile/page.tsx # Account settings page (protected)src/components/auth/
user-menu.tsx # Dropdown with auth state
src/components/profile/
profile-header.tsx # User info with edit mode
change-email.tsx # Email change form
change-password.tsx # Password change form
sessions.tsx # Session management
resend-verification.tsx # Email verification reminder
delete-account.tsx # Account deletion with confirmation
src/app/
profile/page.tsx # Account settings page (protected)Better Auth Protected Routes
Add server-side route protection to enforce authentication on specific pages while keeping others public.
Core Pattern: Server-Side Session Check
The standard pattern for protecting pages uses server-side session validation with redirect:
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function ProtectedPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return <div>Welcome, {session.user.name}</div>;
}import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function ProtectedPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return <div>Welcome, {session.user.name}</div>;
}This pattern:
- Runs entirely on the server (no client-side flash)
- Redirects unauthenticated users before rendering
- Provides the session data for use in the page
Example: Public Landing Page
Create a public landing page with sign-in/sign-up buttons:
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function HomePage() {
return (
<div className="min-h-dvh flex flex-col items-center justify-center gap-8 p-4">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">
Welcome to Your App
</h1>
<p className="text-xl text-muted-foreground max-w-md">
Sign in to access your dashboard and start using the app.
</p>
</div>
<div className="flex gap-4">
<Link href="/sign-in">
<Button variant="outline" size="lg">
Sign in
</Button>
</Link>
<Link href="/sign-up">
<Button size="lg">Get started</Button>
</Link>
</div>
</div>
);
}import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function HomePage() {
return (
<div className="min-h-dvh flex flex-col items-center justify-center gap-8 p-4">
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold tracking-tight">
Welcome to Your App
</h1>
<p className="text-xl text-muted-foreground max-w-md">
Sign in to access your dashboard and start using the app.
</p>
</div>
<div className="flex gap-4">
<Link href="/sign-in">
<Button variant="outline" size="lg">
Sign in
</Button>
</Link>
<Link href="/sign-up">
<Button size="lg">Get started</Button>
</Link>
</div>
</div>
);
}Example: Protected Chat Page
Move your main app functionality to a protected route:
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { ChefHat } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { getUserChats } from "@/lib/chat/queries";
import { ChatList } from "@/components/chats/chat-list";
import { UserMenu } from "@/components/auth/user-menu";
import { ThemeSelector } from "@/components/themes/selector";
export const metadata: Metadata = {
title: "Your Chats",
description: "View and manage your AI conversations.",
};
export default async function ChatsPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
const chats = await getUserChats(session.user.id);
return (
<div className="min-h-screen bg-gradient-to-b from-background via-background to-muted/20">
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary">
<ChefHat className="h-5 w-5 text-primary-foreground" />
</div>
<span className="font-mono text-lg font-semibold tracking-tight">
Your App
</span>
</Link>
</div>
<div className="flex items-center gap-2">
<ThemeSelector />
<UserMenu />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Your Chats</h1>
<p className="text-muted-foreground mt-1">
View and manage your conversations
</p>
</div>
<ChatList initialChats={chats} />
</main>
</div>
);
}import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { ChefHat } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { getUserChats } from "@/lib/chat/queries";
import { ChatList } from "@/components/chats/chat-list";
import { UserMenu } from "@/components/auth/user-menu";
import { ThemeSelector } from "@/components/themes/selector";
export const metadata: Metadata = {
title: "Your Chats",
description: "View and manage your AI conversations.",
};
export default async function ChatsPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
const chats = await getUserChats(session.user.id);
return (
<div className="min-h-screen bg-gradient-to-b from-background via-background to-muted/20">
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary">
<ChefHat className="h-5 w-5 text-primary-foreground" />
</div>
<span className="font-mono text-lg font-semibold tracking-tight">
Your App
</span>
</Link>
</div>
<div className="flex items-center gap-2">
<ThemeSelector />
<UserMenu />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Your Chats</h1>
<p className="text-muted-foreground mt-1">
View and manage your conversations
</p>
</div>
<ChatList initialChats={chats} />
</main>
</div>
);
}Auth Pages: Redirect Authenticated Users
Auth pages should redirect already-authenticated users away:
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SignIn } from "@/components/auth/sign-in";
export default async function SignInPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats"); // Redirect to app when already signed in
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<SignIn />
</main>
);
}import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SignIn } from "@/components/auth/sign-in";
export default async function SignInPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats"); // Redirect to app when already signed in
}
return (
<main className="min-h-dvh flex items-center justify-center p-4">
<SignIn />
</main>
);
}Route Structure
A typical app has three types of routes:
| Route Type | Example | Auth Behavior |
|---|---|---|
| Public | /, /pricing | Anyone can access |
| Auth | /sign-in, /sign-up | Redirect to app if signed in |
| Protected | /chats, /profile | Redirect to sign-in if not signed in |
Example Directory Structure
src/app/
page.tsx # Public landing page
pricing/page.tsx # Public pricing page
sign-in/page.tsx # Auth: redirects if signed in
sign-up/page.tsx # Auth: redirects if signed in
forgot-password/page.tsx
reset-password/page.tsx
verify-email/page.tsx
chats/ # Protected: requires auth
page.tsx # Chat list
[chatId]/page.tsx # Individual chat
profile/page.tsx # Protected: account settingssrc/app/
page.tsx # Public landing page
pricing/page.tsx # Public pricing page
sign-in/page.tsx # Auth: redirects if signed in
sign-up/page.tsx # Auth: redirects if signed in
forgot-password/page.tsx
reset-password/page.tsx
verify-email/page.tsx
chats/ # Protected: requires auth
page.tsx # Chat list
[chatId]/page.tsx # Individual chat
profile/page.tsx # Protected: account settingsLoading User Data
For protected pages that need user-specific data, fetch it server-side after authentication:
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
// Fetch user-specific data using the session
const [chats, subscription] = await Promise.all([
getUserChats(session.user.id),
getUserSubscription(session.user.id),
]);
return (
<Dashboard user={session.user} chats={chats} subscription={subscription} />
);
}export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
// Fetch user-specific data using the session
const [chats, subscription] = await Promise.all([
getUserChats(session.user.id),
getUserSubscription(session.user.id),
]);
return (
<Dashboard user={session.user} chats={chats} subscription={subscription} />
);
}Callback URLs
When redirecting to sign-in, you may want to return users to their original destination. The auth components support callback URLs:
// In your SignIn component
await signIn.email({
email,
password,
callbackURL: "/chats", // Where to go after sign in
});// In your SignIn component
await signIn.email({
email,
password,
callbackURL: "/chats", // Where to go after sign in
});For dynamic redirect-back behavior, you can use search params:
// Protected page: redirect with return URL
if (!session) {
redirect(`/sign-in?returnTo=${encodeURIComponent("/chats/123")}`);
}
// Sign-in page: read the return URL
const searchParams = await props.searchParams;
const returnTo = searchParams.returnTo || "/chats";
// After sign in success
router.push(returnTo);// Protected page: redirect with return URL
if (!session) {
redirect(`/sign-in?returnTo=${encodeURIComponent("/chats/123")}`);
}
// Sign-in page: read the return URL
const searchParams = await props.searchParams;
const returnTo = searchParams.returnTo || "/chats";
// After sign in success
router.push(returnTo);Summary
- Public pages - No session check needed
- Auth pages - Redirect away if already signed in
- Protected pages - Redirect to sign-in if not authenticated
- Use
auth.api.getSession()server-side for immediate protection without flash - Fetch user-specific data after validating the session
Working with Authentication
Use Better Auth for client and server-side authentication. Covers session access, protected routes, sign in/out, and fetching user data.
Implement Working with Authentication
Use Better Auth for client and server-side authentication. Covers session access, protected routes, sign in/out, and fetching user data.
See:
- Resource:
using-authenticationin Fullstack Recipes - URL: https://fullstackrecipes.com/recipes/using-authentication
Client-Side Authentication
Use the auth client hooks in React components:
"use client";
import { useSession, signOut } from "@/lib/auth/client";
export function UserMenu() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <a href="/sign-in">Sign In</a>;
return (
<div>
<span>{session.user.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}"use client";
import { useSession, signOut } from "@/lib/auth/client";
export function UserMenu() {
const { data: session, isPending } = useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <a href="/sign-in">Sign In</a>;
return (
<div>
<span>{session.user.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}Server-Side Session Access
Get the session in Server Components and API routes:
import { auth } from "@/lib/auth/server";
import { headers } from "next/headers";
// In a Server Component
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return <div>Not signed in</div>;
}
return <div>Hello, {session.user.name}</div>;
}import { auth } from "@/lib/auth/server";
import { headers } from "next/headers";
// In a Server Component
export default async function Page() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return <div>Not signed in</div>;
}
return <div>Hello, {session.user.name}</div>;
}// In an API route
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
// Use session.user.id for queries...
}// In an API route
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
// Use session.user.id for queries...
}Protected Pages Pattern
Redirect unauthenticated users:
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function ProtectedPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return <Dashboard user={session.user} />;
}import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function ProtectedPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
return <Dashboard user={session.user} />;
}Auth Pages Pattern
Redirect authenticated users away from auth pages:
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function SignInPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats"); // Already signed in
}
return <SignIn />;
}import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
export default async function SignInPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (session) {
redirect("/chats"); // Already signed in
}
return <SignIn />;
}Signing In
import { signIn } from "@/lib/auth/client";
// Email/password
await signIn.email({
email: "user@example.com",
password: "password",
callbackURL: "/chats",
});
// Social provider
await signIn.social({
provider: "google",
callbackURL: "/chats",
});import { signIn } from "@/lib/auth/client";
// Email/password
await signIn.email({
email: "user@example.com",
password: "password",
callbackURL: "/chats",
});
// Social provider
await signIn.social({
provider: "google",
callbackURL: "/chats",
});Signing Up
import { signUp } from "@/lib/auth/client";
await signUp.email({
email: "user@example.com",
password: "password",
name: "John Doe",
callbackURL: "/verify-email",
});import { signUp } from "@/lib/auth/client";
await signUp.email({
email: "user@example.com",
password: "password",
name: "John Doe",
callbackURL: "/verify-email",
});Signing Out
import { signOut } from "@/lib/auth/client";
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
},
},
});import { signOut } from "@/lib/auth/client";
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/");
},
},
});Fetching User Data After Auth
In protected pages, fetch user-specific data after validating the session:
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
const [chats, profile] = await Promise.all([
getUserChats(session.user.id),
getUserProfile(session.user.id),
]);
return <Dashboard chats={chats} profile={profile} />;
}export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/sign-in");
}
const [chats, profile] = await Promise.all([
getUserChats(session.user.id),
getUserProfile(session.user.id),
]);
return <Dashboard chats={chats} profile={profile} />;
}