Back to recipes

Authentication

7 recipes

Complete authentication system with Better Auth, email verification, password reset, protected routes, and account management.

Cookbooks/Plugins

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:

json
{
  "mcpServers": {
    "resend": {
      "url": "https://resend.com/docs/mcp"
    }
  }
}

Step 1: Install the packages

bash
bun add resend @react-email/components

The @react-email/components package is required for Resend to render React email templates.

Step 2: Add environment variables

Add to your .env.development:

env
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:

ts
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:

ts
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:

ts
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

typescript
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:

tsx
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 template

References


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:

json
{
  "mcpServers": {
    "better-auth": {
      "url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp"
    }
  }
}

Step 1: Install the package

bash
bun add better-auth

Step 2: Add environment variables

Add the secret to your .env.development (synced to Vercel):

env
BETTER_AUTH_SECRET="your-random-secret-at-least-32-chars"

Generate a secret using:

bash
openssl rand -base64 32

Add the URL to your .env.local (local override):

env
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:

ts
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:

ts
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 .tsx instead of .ts to support JSX email templates when you add Better Auth Emails later.

tsx
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:

ts
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:

ts
"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

bash
bun run db:generate
bun run db:migrate

Usage

Sign Up

typescript
import { signUp } from "@/lib/auth/client";

await signUp.email({
  email: "user@example.com",
  password: "securepassword",
  name: "John Doe",
});

Sign In

typescript
import { signIn } from "@/lib/auth/client";

await signIn.email({
  email: "user@example.com",
  password: "securepassword",
});

Sign Out

typescript
import { signOut } from "@/lib/auth/client";

await signOut();

Get Session (Client)

tsx
"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)

typescript
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

tsx
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 handler

Adding Social Providers

To add OAuth providers like GitHub, Google, or Vercel, first add them as fields in your auth config:

ts
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:

tsx
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:

typescript
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:

tsx
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&apos;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&apos;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:

tsx
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&apos;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&apos;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:

tsx
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&apos;t request this change, please ignore this email or
          contact support if you&apos;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&apos;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:

tsx
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&apos;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&apos;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&apos;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:

tsx
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

typescript
import { authClient } from "@/lib/auth/client";

await authClient.requestPasswordReset({
  email: "user@example.com",
  redirectTo: "/reset-password",
});

Reset Password

typescript
import { authClient } from "@/lib/auth/client";

await authClient.resetPassword({
  newPassword: "newSecurePassword",
  token: "token-from-url",
});

Send Verification Email

typescript
import { authClient } from "@/lib/auth/client";

await authClient.sendVerificationEmail({
  email: "user@example.com",
  callbackURL: "/chats",
});

Change Email

typescript
import { authClient } from "@/lib/auth/client";

await authClient.changeEmail({
  newEmail: "newemail@example.com",
  callbackURL: "/profile",
});

Change Password

typescript
import { authClient } from "@/lib/auth/client";

await authClient.changePassword({
  currentPassword: "oldPassword",
  newPassword: "newPassword",
  revokeOtherSessions: true,
});

Delete Account

typescript
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.tsx

Better 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:

tsx
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.

tsx
"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&apos;t have an account?{" "}
          <Link
            href="/sign-up"
            className="text-primary font-medium hover:underline"
          >
            Create account
          </Link>
        </p>
      </CardFooter>
    </Card>
  );
}

Sign Up Component

tsx
"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&apos;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

tsx
"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&apos;ve sent
            password reset instructions.
          </CardDescription>
        </CardHeader>
        <CardContent className="text-center text-sm text-muted-foreground">
          <p>
            Didn&apos;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&apos;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

tsx
"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

tsx
"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

tsx
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

tsx
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

tsx
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

tsx
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

tsx
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:

tsx
// 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:

tsx
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.tsx

Better 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:

tsx
"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:

tsx
"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

tsx
"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&apos;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

tsx
"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

tsx
"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:

tsx
"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

tsx
"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:

tsx
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)

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:

tsx
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:

tsx
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:

tsx
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:

tsx
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 TypeExampleAuth Behavior
Public/, /pricingAnyone can access
Auth/sign-in, /sign-upRedirect to app if signed in
Protected/chats, /profileRedirect 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 settings

Loading User Data

For protected pages that need user-specific data, fetch it server-side after authentication:

tsx
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:

tsx
// 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:

tsx
// 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

  1. Public pages - No session check needed
  2. Auth pages - Redirect away if already signed in
  3. Protected pages - Redirect to sign-in if not authenticated
  4. Use auth.api.getSession() server-side for immediate protection without flash
  5. 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-authentication in Fullstack Recipes
  • URL: https://fullstackrecipes.com/recipes/using-authentication

Client-Side Authentication

Use the auth client hooks in React components:

tsx
"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:

typescript
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>;
}
typescript
// 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:

tsx
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:

tsx
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

typescript
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

typescript
import { signUp } from "@/lib/auth/client";

await signUp.email({
  email: "user@example.com",
  password: "password",
  name: "John Doe",
  callbackURL: "/verify-email",
});

Signing Out

typescript
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:

tsx
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} />;
}

References