Kanad
|

About

Projects

Blogs

Contact

|

©2026 / Kanad Shee/ Building Scalabale Products

©2025 / Kanad Shee

On This Page

Better Auth

Wednesday, January 14, 2026

•

By Kanad Shee

Better Auth with Next.js, shadcn UI, Tailwind CSS, OAuth, and more

Better Auth

Integrate Better Auth with Next.js

A comprehensive guide to implementing Better Auth in your Next.js application with email verification, social authentication, and beautiful form components using shadcn UI.

Integrating authentication into a modern web application can feel overwhelming—especially when juggling complex email verification flows, third-party OAuth providers, and secure session management. But with Better Auth, the process transforms from daunting to downright elegant.

In this comprehensive guide, I walk through how remarkably simple it is to implement Better Auth in a production-ready application. This modern authentication library brings a type-safe API, exceptional TypeScript support, and a highly flexible configuration system that integrates seamlessly with the latest Next.js App Router and server-side architecture.

I've successfully implemented Better Auth in my Autonode automation platform to create a secure, scalable, and user-friendly authentication system without reinventing the wheel. From handling email/password credentials with custom validation schemas to integrating OAuth providers like Google and GitHub, the Better Auth experience felt intuitive, developer-friendly, and production-ready. What truly stands out is how Better Auth's session handling and Prisma adapter allowed me to maintain a performant and secure application, all while keeping the codebase clean and maintainable.

The beauty of Better Auth lies in its plugin architecture and built-in email verification—features that would typically require days of custom implementation. Combined with shadcn UI's Base components, React Hook Form, and Zod validation, the result is an authentication system that's not just functional, but delightful to use for both developers and end-users.

Whether you're building a SaaS platform, an AI automation tool, or a community-driven application, Better Auth is a drop-in solution that scales effortlessly with your project. This guide will show you exactly how I built a complete authentication flow—from beautiful sign-up forms to email verification—and how you can replicate it in your own application with minimal configuration and a focus on best practices.


Introduction

This guide walks you through implementing Better Auth—a modern, type-safe authentication library for Next.js—complete with:

  • Email & Password Authentication
  • Social OAuth (Google & GitHub)
  • Email Verification with Nodemailer
  • Beautiful shadcn UI Forms
  • Protected Routes
  • PostgreSQL Database with Prisma
  • Tailwind CSS Styling

Tech Stack

TechnologyVersionPurpose
Next.js16.1.0React framework with App Router
Better Auth1.4.8Authentication library
Prisma7.2.0Database ORM
PostgreSQLLatestDatabase
shadcn UILatestUI components (Base Mira style)
Tailwind CSS4.xStyling framework
Nodemailer7.0.12Email service
React Hook Form7.69.0Form management
Zod4.2.1Schema validation
Motion12.23.26Animations

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ or Bun runtime installed
  • PostgreSQL database running (local or cloud)
  • SMTP email service credentials
  • Google OAuth credentials (optional)
  • GitHub OAuth credentials (optional)
  • Basic knowledge of Next.js App Router

Project Structure

src/
├── app/
│   ├── (auth)/                    # Authentication routes group
│   │   ├── layout.tsx            # Auth layout
│   │   ├── sign-in/
│   │   │   └── page.tsx          # Sign in page
│   │   ├── sign-up/
│   │   │   └── page.tsx          # Sign up page
│   │   ├── verify-email/
│   │   │   ├── page.tsx          # Email verification page
│   │   │   └── resend-verification-button.tsx
│   │   └── email-verified/
│   │       └── page.tsx          # Success page
│   ├── (protected)/              # Protected routes group
│   │   ├── layout.tsx            # Protected layout with auth check
│   │   └── (dashboard)/
│   │       └── page.tsx          # Dashboard (example protected page)
│   ├── api/
│   │   └── auth/
│   │       └── [...all]/
│   │           └── route.ts      # Better Auth API routes
│   └── actions/
│       └── check-user-email.ts   # Server action for email validation
├── features/
│   └── auth/
│       ├── components/
│       │   ├── login-form.tsx    # Sign in form
│       │   ├── register-form.tsx # Sign up form
│       │   └── social-buttons.tsx # OAuth buttons
│       └── schema/
│           └── index.ts          # Zod validation schemas
├── lib/
│   ├── auth.ts                   # Better Auth server config
│   ├── auth-client.ts            # Better Auth client config
│   ├── auth-utils.ts             # Auth helper functions
│   ├── db.ts                     # Prisma client
│   └── mail-sender.ts            # Email sending utility
└── prisma/
    └── schema.prisma             # Database schema

Authentication Flow

The authentication flow will work as

Auth_Flows


Installation & Setup

Install Dependencies

# Core dependencies
npm install better-auth @prisma/client nodemailer

# Dev dependencies
npm install -D prisma @types/nodemailer

# UI & Form dependencies
npm install react-hook-form @hookform/resolvers zod
npm install motion lucide-react @tabler/icons-react

# Shadcn UI (Base Mira style)
npx shadcn@latest init

When initializing shadcn, use these settings:

✔ Would you like to use TypeScript? yes
✔ Which style would you like to use? › Base Mira
✔ Which color would you like to use as base color? › Neutral
✔ Where is your global CSS file? › src/app/globals.css
✔ Would you like to use CSS variables for colors? yes
✔ Are you using a custom tailwind prefix? no
✔ Where is your tailwind.config located? › (detect automatically)

Install Required shadcn Components

npx shadcn@latest add button card input field

Better Auth Configuration

Install Better Auth Package

Add Better Auth to your project:

npm install better-auth

Configure Environment Variables

Set a Better Auth secret in your .env file:

BETTER_AUTH_SECRET=<your_own_secret>

Set the base URL for Better Auth:

BETTER_AUTH_URL=http://localhost:3000 # Change in Production

Create Better Auth Instance

Create an Better Auth instance in @/lib/auth.ts:

import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { PrismaClient } from '@/generated/prisma/client';

const prisma = new PrismaClient();
export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: 'sqlite',
  }),
});

Generate Better Auth Schema

Generate the database schema needed for Better Auth:

npx @better-auth/cli generate

Migrate Schema to Database

Apply the schema migrations to your database:

npx @better-auth/cli migrate

Verify Prisma Schema

After running the above commands, your schema.prisma file should look like this:

File: prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// Better Auth requires these exact table structures
model User {
  id            String   @id
  name          String
  email         String   @unique
  emailVerified Boolean  @default(false)
  image         String?
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  sessions  Session[]
  accounts  Account[]

  @@map("user")
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  ipAddress String?
  userAgent String?

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("session")
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  userId                String
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt

  @@index([userId])
  @@map("account")
}

model Verification {
  id         String   @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  @@index([identifier])
  @@map("verification")
}

Generate Prisma Client and Run Migrations

# Generate Prisma Client
npx prisma generate

# Create and apply migration
npx prisma migrate dev --name init

Environment Variables Setup

Create a .env file in your project root:

File: .env

# Database
DATABASE_URL="postgresql://username:password@localhost:5432/your_database"

# App URL
NEXT_PUBLIC_APP_URL="http://localhost:3000"
BETTER_AUTH_SECRET="your-super-secret-key-here-minimum-32-chars"

# Email Service (Using Gmail as example)
MAIL_HOST="smtp.gmail.com"
MAIL_USER="your-email@gmail.com"
MAIL_PASS="your-app-specific-password"

# Google OAuth (Optional)
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

# GitHub OAuth (Optional)
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"

Important Notes:

  • Generate BETTER_AUTH_SECRET using: openssl rand -base64 32
  • For Gmail, enable 2FA and create an App Password
  • OAuth credentials can be obtained from Google Cloud Console and GitHub Developer Settings

Better Auth Server Configuration

File: src/lib/db.ts

import { PrismaClient } from '@/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

const globalForPrisma = global as unknown as {
  prisma: PrismaClient;
};

const adapter = new PrismaPg({
  connectionString: process.env.DATABASE_URL,
});

const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    adapter,
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

export default prisma;

File: src/lib/auth.ts

import prisma from './db';
import { sendEmail } from './mail-sender';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: 'postgresql',
  }),

  emailAndPassword: {
    enabled: true,
    autoSignIn: true,
    requireEmailVerification: true,
  },

  emailVerification: {
    sendOnSignUp: true,
    async sendVerificationEmail({ user, url }) {
      await sendEmail({
        email: user.email,
        title: 'Verify Your Email',
        body: 'Click this button to verify your email!',
        url,
      });
    },
  },

  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
});

File: src/app/api/auth/[...all]/route.ts

import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';

export const { POST, GET } = toNextJsHandler(auth);

Better Auth Client Configuration

File: src/lib/auth-client.ts

import { nextCookies } from 'better-auth/next-js';
import { createAuthClient } from 'better-auth/react';

export const authClient = createAuthClient({
  plugins: [nextCookies()],
});

Email Service Setup

File: src/lib/mail-sender.ts

import nodemailer from 'nodemailer';

interface EmailSendingProps {
  email: string;
  title: string;
  body: string;
  url?: string;
}

export const sendEmail = async ({
  email,
  body,
  title,
  url,
}: EmailSendingProps) => {
  try {
    const transporter = nodemailer.createTransport({
      host: process.env.MAIL_HOST,
      auth: {
        user: process.env.MAIL_USER,
        pass: process.env.MAIL_PASS,
      },
    });

    const confirmationLink = url;

    const skeleton = `
  <div style="background-color: #ffffff; padding: 40px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #1a1a1a; line-height: 1.6;">
    <div style="max-width: 500px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 12px; padding: 40px; text-align: center; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);">
      <h2 style="margin-top: 0; font-size: 24px; font-weight: 600; color: #111827; letter-spacing: -0.025em;">
        Verify your email
      </h2>
      <div style="margin-top: 24px; font-size: 16px; color: #4b5563;">
        ${body}
      </div>
      <div style="margin-top: 32px;">
        <a href="${confirmationLink}" style="display: inline-block; background-color: #111827; color: #ffffff; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-size: 15px; font-weight: 500; transition: background-color 0.2s ease;">
          Verify Email Address
        </a>
      </div>
      <p style="margin-top: 32px; color: #9ca3af; font-size: 13px;">
        This link will expire in <span style="color: #6b7280; font-weight: 500;">5 minutes</span>.
      </p>
    </div>
    <div style="max-width: 500px; margin: 20px auto; text-align: center;">
      <p style="font-size: 12px; color: #9ca3af;">
        If you didn't request this, you can safely ignore this email.
      </p>
    </div>
  </div>
`;

    await transporter.sendMail({
      from: `"Autonode – AI Workflow Automation" <${process.env.MAIL_USER}>`,
      to: email,
      subject: title,
      html: skeleton,
    });

    return {
      success: 'Confirmation mail has been sent to email!',
    };
  } catch {
    return {
      error: 'Some error occurred while sending email.',
    };
  }
};

Authentication Helper Functions

File: src/lib/auth-utils.ts

import { auth } from './auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export const requireAuth = async () => {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    redirect('/sign-in');
  }

  return session;
};

export const requireUnAuth = async () => {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (session) {
    redirect('/dashboard');
  }
};

export async function getServerSession() {
  return auth.api.getSession({
    headers: await headers(),
  });
}

export async function getFullSession() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return null;
  }

  const accounts = await auth.api.listUserAccounts({
    headers: await headers(),
  });

  return {
    ...session,
    accounts,
  };
}

Building Authentication Forms

Create Validation Schema

File: src/features/auth/schema/index.ts

import { z } from 'zod';

const passwordValidation = z
  .string()
  .min(8, {
    message: 'Password should have min length of 8.',
  })
  .max(15, {
    message: 'Password is too long and should be between 8 to 15 chars.',
  })
  .regex(/[a-z]/, { message: 'Password must contain at least one lowercase.' })
  .regex(/[A-Z]/, {
    message: 'Password should have one uppercase',
  })
  .regex(/\d/, {
    message: 'Password must contain at least one number',
  })
  .regex(/[^A-Za-z0-9]/, {
    message: 'Password must contain at least one special character',
  });

export const REGISTER_SCHEMA = z
  .object({
    name: z
      .string({ message: 'Name is required!' })
      .min(3, { message: 'Name must be at least 3 characters.' })
      .max(30, {
        message: 'Name can be at max 30 characters long.',
      }),
    email: z.string().email({ message: 'Please provide valid email address' }),
    password: passwordValidation,
    confirmPassword: passwordValidation,
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Both passwords has to be same.',
    path: ['confirmPassword'],
  });

export const LOGIN_SCHEMA = z.object({
  email: z.string().email({ message: 'Please provide valid email' }),
  password: passwordValidation,
});

export type REGISTER_FORM_TYPE = z.infer<typeof REGISTER_SCHEMA>;
export type LOGIN_FORM_TYPE = z.infer<typeof LOGIN_SCHEMA>;

Build Sign In Form Component

File: src/features/auth/components/login-form.tsx

'use client';

import { LOGIN_FORM_TYPE, LOGIN_SCHEMA } from '../schema';
import { OAuthButtons } from './social-buttons';
import { Button } from '@/components/ui/button';
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from '@/components/ui/card';
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/auth-client';
import { zodResolver } from '@hookform/resolvers/zod';
import { IconEye, IconEyeOff, IconLock } from '@tabler/icons-react';
import { ChevronRightIcon, Loader2Icon, MailIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';

export const LoginForm = () => {
  const router = useRouter();
  const [errMesg, setErrMesg] = useState<string | null>(null);
  const [showPassword, setShowPassword] = useState<boolean>(false);

  const form = useForm<LOGIN_FORM_TYPE>({
    resolver: zodResolver(LOGIN_SCHEMA),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const isPending = form.formState.isSubmitting;

  const onFormSubmit = async (values: LOGIN_FORM_TYPE) => {
    if (!values.email || !values.password) return;

    setErrMesg(null);

    const { error } = await authClient.signIn.email(
      {
        email: values.email,
        password: values.password,
        callbackURL: '/dashboard',
      },
      {
        onSuccess: () => {
          toast.success('Logged In Successfully!');
          router.push('/dashboard');
        },
        onError: ({ error }) => {
          if (error.code === 'EMAIL_NOT_VERIFIED') {
            router.push('/verify-email');
          }
          setErrMesg(error.message ?? 'Something went wrong while login.');
          toast.error(
            error.message ?? 'Something went wrong while signing in!',
          );

          form.reset();
        },
      },
    );

    if (error) {
      setErrMesg(
        error?.message
          ? error.message
          : 'Some error occurred while signing you in!',
      );
    }
  };

  return (
    <Card className="mx-auto min-w-95 space-y-6 p-4 py-5 md:w-md md:p-5 md:py-8">
      <CardHeader>
        <h2 className="text-2xl font-bold tracking-tight md:text-3xl">
          Welcome Back
        </h2>
        <p className="text-muted-foreground text-sm font-medium">
          Login to streamline your pending tasks
        </p>
      </CardHeader>

      <CardContent className="space-y-4">
        <OAuthButtons />

        <div className="flex items-center gap-x-2">
          <span className="bg-border h-px flex-1"></span>
          <p className="text-muted-foreground my-5">Or, Continue With</p>
          <span className="bg-border h-px flex-1"></span>
        </div>

        <form
          onSubmit={form.handleSubmit(onFormSubmit)}
          className={'mt-8 space-y-5'}
        >
          <FieldGroup>
            <Controller
              name="email"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field>
                  <FieldLabel htmlFor={field.name}>Email</FieldLabel>
                  <div className="relative">
                    <span className="bg-muted absolute top-1/2 left-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded p-0.5 shadow">
                      <MailIcon className="size-4.5" />
                    </span>
                    <Input
                      {...field}
                      type="email"
                      className="pl-10"
                      disabled={isPending}
                      placeholder="adrino@matrix.dev"
                      required
                    />
                  </div>
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>

          <FieldGroup>
            <Controller
              name="password"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field>
                  <FieldLabel htmlFor={field.name}>Password</FieldLabel>
                  <div className="relative">
                    <span className="bg-muted absolute top-1/2 left-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded p-0.5 shadow">
                      <IconLock className="size-5" />
                    </span>
                    <span
                      onClick={() => setShowPassword((prev) => !prev)}
                      className="absolute top-1/2 right-1 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
                    >
                      {showPassword ? (
                        <IconEye className="size-5" />
                      ) : (
                        <IconEyeOff className="size-5" />
                      )}
                    </span>
                    <Input
                      {...field}
                      type={showPassword ? 'text' : 'password'}
                      disabled={isPending}
                      placeholder="********"
                      required
                      className="px-10"
                    />
                  </div>
                  {fieldState.error && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>

          {errMesg && (
            <p className="bg-destructive/10 w-full rounded-md px-4 py-2 text-sm text-rose-300">
              {errMesg}
            </p>
          )}

          <Button
            type="submit"
            size={'lg'}
            disabled={isPending}
            className={'group mt-7 w-full'}
          >
            {isPending ? 'Signing In ...' : 'SIGN IN'}
            {isPending ? (
              <Loader2Icon className="animate-spin" />
            ) : (
              <ChevronRightIcon className="transition-all duration-200 ease-in-out group-hover:translate-x-1.5" />
            )}
          </Button>
        </form>
      </CardContent>

      <CardFooter>
        <Link
          href={'/sign-up'}
          className="text-muted-foreground mt-3 ml-auto w-fit underline-offset-4 transition duration-300 hover:text-blue-400 hover:underline"
        >
          Don&apos;t have an account?
        </Link>
      </CardFooter>
    </Card>
  );
};

Login Form Features:

  • shadcn UI Components Used:

    • Card, CardHeader, CardContent, CardFooter - Structure the form layout with consistent styling
    • Field, FieldGroup, FieldLabel, FieldError - Form field components from shadcn Base UI
    • Input - Text input component with custom styling and icon support
    • Button - Primary action button with loading states
  • Form Fields:

    • Email Field - Uses MailIcon from Lucide React, validates email format with Zod
    • Password Field - Uses IconLock from Tabler Icons, includes show/hide toggle with IconEye/IconEyeOff
    • Both fields display validation errors using FieldError component
  • On Form Submit:

    1. Validates input using Zod schema (LOGIN_SCHEMA)
    2. Calls authClient.signIn.email() with email, password, and callback URL
    3. Shows loading state with isPending while processing
    4. Disables all inputs during submission
  • On Success:

    • Displays success toast notification: "Logged In Successfully!"
    • Redirects user to /dashboard page
    • Session is automatically created and stored in cookies
  • On Error:

    • If email is not verified: Redirects to /verify-email page
    • Error message is stored in errMesg state
    • Displays error in red alert box using bg-destructive/10 styling
    • Shows error toast notification
    • Form is reset to clear password field
    • Common errors: Invalid credentials, unverified email, network issues

Build Sign Up Form Component

File: src/features/auth/components/register-form.tsx

'use client';

import { REGISTER_FORM_TYPE, REGISTER_SCHEMA } from '../schema';
import { OAuthButtons } from './social-buttons';
import { Button } from '@/components/ui/button';
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from '@/components/ui/card';
import {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/auth-client';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  IconEye,
  IconEyeOff,
  IconLock,
  IconLockCheck,
} from '@tabler/icons-react';
import {
  ChevronRightIcon,
  Loader2Icon,
  MailIcon,
  UserIcon,
} from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';

export const RegisterForm = () => {
  const [errMesg, setErrMesg] = useState<string | null>(null);
  const [successMesg, setSuccessMesg] = useState<string | null>(null);
  const [showPassword, setShowPassword] = useState<boolean>(false);
  const [showConfirmPassword, setShowConfirmPassword] =
    useState<boolean>(false);

  const form = useForm<REGISTER_FORM_TYPE>({
    resolver: zodResolver(REGISTER_SCHEMA),
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
    },
  });

  const isPending = form.formState.isSubmitting;

  const onFormSubmit = async (values: REGISTER_FORM_TYPE) => {
    if (values.password.trim() !== values.confirmPassword.trim()) return;
    if (!values.name || !values.email || !values.password) return;

    setErrMesg(null);
    setSuccessMesg(null);

    const { error } = await authClient.signUp.email(
      {
        name: values.name,
        email: values.email,
        password: values.password,
        callbackURL: '/email-verified',
      },
      {
        onSuccess: () => {
          form.reset();
          toast.success('You have been registered successfully!');
          setSuccessMesg('Verificaion email sent successfully!');
        },
        onError: () => {
          form.reset();
          toast.error('Error while registering');
        },
      },
    );

    if (error) {
      setErrMesg(error?.message ? error.message : 'Some error occurred!');
    }
  };

  return (
    <Card className="mx-auto min-w-95 space-y-6 p-4 py-5 md:w-md md:p-5 md:py-8">
      <CardHeader>
        <h2 className="text-2xl font-bold tracking-tight md:text-3xl">
          Become a part of Autonode
        </h2>
        <p className="text-muted-foreground text-sm font-medium">
          Orchestrate tasks with a visual node-based interface.
        </p>
      </CardHeader>

      <CardContent className="space-y-4">
        <form
          onSubmit={form.handleSubmit(onFormSubmit)}
          className={'space-y-5'}
        >
          <FieldGroup>
            <Controller
              name="name"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field>
                  <FieldLabel htmlFor={field.name}>Name</FieldLabel>
                  <div className="relative">
                    <span className="bg-muted absolute top-1/2 left-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded p-0.5 shadow">
                      <UserIcon className="size-4.5" />
                    </span>
                    <Input
                      {...field}
                      disabled={isPending}
                      type="text"
                      placeholder="Martin Adrino"
                      required
                      className="pl-10"
                    />
                  </div>
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>

          <FieldGroup>
            <Controller
              name="email"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field>
                  <FieldLabel htmlFor={field.name}>Email</FieldLabel>
                  <div className="relative">
                    <span className="bg-muted absolute top-1/2 left-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded p-0.5 shadow">
                      <MailIcon className="size-4.5" />
                    </span>
                    <Input
                      {...field}
                      disabled={isPending}
                      type="email"
                      placeholder="adrino@matrix.dev"
                      required
                      className="pl-10"
                    />
                  </div>
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>

          <FieldGroup>
            <Controller
              name="password"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field>
                  <FieldLabel htmlFor={field.name}>Password</FieldLabel>
                  <div className="relative">
                    <span className="bg-muted absolute top-1/2 left-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded p-0.5 shadow">
                      <IconLock className="size-4.5" />
                    </span>
                    <span
                      onClick={() => setShowPassword((prev) => !prev)}
                      className="absolute top-1/2 right-1 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
                    >
                      {showPassword ? (
                        <IconEye className="size-5" />
                      ) : (
                        <IconEyeOff className="size-5" />
                      )}
                    </span>
                    <Input
                      {...field}
                      type={showPassword ? 'text' : 'password'}
                      disabled={isPending}
                      placeholder="********"
                      required
                      className="px-10"
                    />
                  </div>
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>

          <FieldGroup>
            <Controller
              name="confirmPassword"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field>
                  <FieldLabel htmlFor={field.name}>Confirm Password</FieldLabel>
                  <div className="relative">
                    <span className="bg-muted absolute top-1/2 left-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded p-0.5 shadow">
                      <IconLockCheck className="size-4.5" />
                    </span>
                    <span
                      onClick={() => setShowConfirmPassword((prev) => !prev)}
                      className="absolute top-1/2 right-1 flex size-7 -translate-y-1/2 cursor-pointer items-center justify-center"
                    >
                      {showConfirmPassword ? (
                        <IconEye className="size-5" />
                      ) : (
                        <IconEyeOff className="size-5" />
                      )}
                    </span>
                    <Input
                      {...field}
                      type={showConfirmPassword ? 'text' : 'password'}
                      placeholder="********"
                      required
                      disabled={isPending}
                      className="px-10"
                    />
                  </div>
                  {fieldState.invalid && (
                    <FieldError errors={[fieldState.error]} />
                  )}
                </Field>
              )}
            />
          </FieldGroup>

          {errMesg && (
            <p className="bg-destructive/10 w-full rounded-md px-4 py-2 text-sm text-rose-300">
              {errMesg}
            </p>
          )}

          {successMesg && (
            <p className="w-full rounded-md bg-emerald-500/10 px-4 py-2 text-sm text-emerald-400">
              {successMesg}
            </p>
          )}

          <Button
            type="submit"
            size={'lg'}
            disabled={isPending}
            className={'group mt-7 w-full'}
          >
            {isPending ? 'Signing UP ...' : 'SIGN UP'}
            {isPending ? (
              <Loader2Icon className="animate-spin" />
            ) : (
              <ChevronRightIcon className="transition-all duration-200 ease-in-out group-hover:translate-x-1.5" />
            )}
          </Button>
        </form>
      </CardContent>

      <CardFooter className="flex flex-col gap-y-3">
        <div className="mb-5 flex w-full items-center gap-x-2">
          <span className="bg-border h-px flex-1" />
          <p className="text-muted-foreground">Or, Continue With</p>
          <span className="bg-border h-px flex-1" />
        </div>

        <div className="w-full">
          <OAuthButtons />
        </div>

        <Link
          href={'/sign-in'}
          className="text-muted-foreground mt-3 ml-auto w-fit underline-offset-4 hover:text-blue-400 hover:underline"
        >
          Already part of Autonode?
        </Link>
      </CardFooter>
    </Card>
  );
};

Register Form Features:

  • shadcn UI Components Used:

    • Card, CardHeader, CardContent, CardFooter - Form structure with shadcn Base Mira styling
    • Field, FieldGroup, FieldLabel, FieldError - Base UI field components for consistent form layout
    • Input - Custom styled input with icon positioning
    • Button - Submit button with loading and hover animations
  • Form Fields:

    • Name Field - Uses UserIcon from Lucide, minimum 3 characters, max 30 characters
    • Email Field - Uses MailIcon, validates email format with Zod
    • Password Field - Uses IconLock from Tabler, includes show/hide toggle, validates strength (min 8 chars, uppercase, lowercase, number, special char)
    • Confirm Password Field - Uses IconLockCheck, must match password field
    • All fields show validation errors using FieldError below each input
  • On Form Submit:

    1. Validates all fields using Zod schema (REGISTER_SCHEMA)
    2. Checks if password and confirmPassword match
    3. Calls authClient.signUp.email() with name, email, password
    4. Shows loading state with spinner icon
    5. All inputs are disabled during submission
  • On Success:

    • Form is reset to clear all fields
    • Success toast notification: "You have been registered successfully!"
    • Green success message displayed: "Verification email sent successfully!"
    • Success message stored in successMesg state with emerald styling
    • User remains on sign-up page to check email for verification link
  • On Error:

    • Error message stored in errMesg state
    • Displays error in red alert box with bg-destructive/10 background
    • Shows error toast notification: "Error while registering"
    • Form is reset to clear password fields
    • Common errors: Email already exists, weak password, network issues

Create Social OAuth Buttons Component

File: src/features/auth/components/social-buttons.tsx

'use client';

import { Button } from '@/components/ui/button';
import { authClient } from '@/lib/auth-client';
import { LoaderIcon } from 'lucide-react';
import { useState } from 'react';
import { FaGithub } from 'react-icons/fa';
import { FcGoogle } from 'react-icons/fc';

export const OAuthButtons = () => {
  const [errMesg, setErrMesg] = useState<string | null>(null);
  const [oauthGoogleLoading, setOauthGoogleLoading] = useState<boolean>(false);
  const [oauthGithubLoading, setOauthGithubLoading] = useState<boolean>(false);

  const handleSocialLogin = async (provider: 'google' | 'github') => {
    setErrMesg(null);

    if (provider === 'google') {
      setOauthGoogleLoading(true);
    } else {
      setOauthGithubLoading(true);
    }

    const { error } = await authClient.signIn.social({
      provider,
      callbackURL: '/dashboard',
    });

    setOauthGoogleLoading(false);
    setOauthGithubLoading(false);

    if (error) {
      setErrMesg(error.message ?? 'Something went wrong!');
    }
  };

  return (
    <div>
      {errMesg && (
        <p className="bg-destructive/10 my-4 w-full rounded-md px-4 py-2 text-sm text-rose-300">
          {errMesg}
        </p>
      )}

      <div className="grid min-w-full grid-cols-1 gap-4 sm:grid-cols-2">
        <Button
          type="button"
          onClick={() => handleSocialLogin('google')}
          variant={'outline'}
          className={'col-span-2 md:col-span-1'}
        >
          {oauthGoogleLoading ? (
            <LoaderIcon className="animate-spin" />
          ) : (
            <FcGoogle />
          )}
          Google
        </Button>

        <Button
          type="button"
          onClick={() => handleSocialLogin('github')}
          variant={'outline'}
          className={'col-span-2 w-full md:col-span-1'}
        >
          {oauthGithubLoading ? (
            <LoaderIcon className="animate-spin" />
          ) : (
            <FaGithub />
          )}
          GitHub
        </Button>
      </div>
    </div>
  );
};

OAuth Buttons Features:

  • shadcn UI Components Used:

    • Button - Outline variant buttons for social login with hover effects
    • Grid layout - Responsive 2-column grid (stacks on mobile)
  • OAuth Providers:

    • Google - Uses FcGoogle colored icon from react-icons
    • GitHub - Uses FaGithub icon from react-icons
    • Each button shows provider-specific loading state with LoaderIcon spinner
  • On Button Click:

    1. Sets provider-specific loading state (oauthGoogleLoading or oauthGithubLoading)
    2. Calls authClient.signIn.social() with provider and callback URL
    3. User is redirected to OAuth provider's consent screen
    4. After authorization, redirected back to app
  • On Success:

    • User is automatically signed in
    • Session created with OAuth account details
    • Redirects to /dashboard page
    • No email verification required for social login
    • User account linked to social provider
  • On Error:

    • Error message stored in errMesg state
    • Red error alert displayed above buttons: "Something went wrong!"
    • Loading state is cleared
    • Common errors: User cancelled authorization, network issues, invalid OAuth credentials
    • User can retry OAuth flow

Create Authentication Pages

File: src/app/(auth)/sign-in/page.tsx

import { LoginForm } from '@/features/auth/components/login-form';
import { requireUnAuth } from '@/lib/auth-utils';
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Sign In | Autonode',
  description:
    'Sign In to Autonode to orchestrate and automate your daily tasks.',
};

const SigninPage = async () => {
  await requireUnAuth();

  return (
    <div className="flex min-h-screen items-center justify-center">
      <LoginForm />
    </div>
  );
};

export default SigninPage;

File: src/app/(auth)/sign-up/page.tsx

import { RegisterForm } from '@/features/auth/components/register-form';
import { requireUnAuth } from '@/lib/auth-utils';
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Sign Up | Autonode',
  description:
    'Become a member of Autonode to orchestrate and automate your daily tasks.',
};

const SignupPage = async () => {
  await requireUnAuth();

  return (
    <div className="flex h-full min-h-screen items-center justify-center">
      <RegisterForm />
    </div>
  );
};

export default SignupPage;

Implementing Route Protection

Create Protected Layout

File: src/app/(protected)/layout.tsx

import { requireAuth } from '@/lib/auth-utils';

export default async function ProtectedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  await requireAuth();
  return children;
}

Create Dashboard Page (Example)

File: src/app/(protected)/(dashboard)/page.tsx

import { getServerSession } from '@/lib/auth-utils';

export default async function DashboardPage() {
  const session = await getServerSession();

  return (
    <div className="container mx-auto py-10">
      <h1 className="text-3xl font-bold">Welcome to Dashboard</h1>
      <p className="mt-4">Hello, {session?.user?.name}!</p>
      <p className="text-muted-foreground">Email: {session?.user?.email}</p>
    </div>
  );
}

Create Verify Email Page

File: src/app/(auth)/verify-email/page.tsx

import { ResendVerificationButton } from './resend-verification-button';
import { getServerSession } from '@/lib/auth-utils';
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';

export const metadata: Metadata = {
  title: 'Verify Email',
};

export default async function VerifyEmailPage() {
  const session = await getServerSession();
  const user = session?.user;

  if (user && user.emailVerified) redirect('/dashboard');

  return (
    <main className="flex h-screen w-screen flex-1 items-center justify-center px-4 text-center">
      <div className="space-y-6">
        <div className="space-y-2">
          <h1 className="text-2xl font-semibold">Verify your Email</h1>
        </div>
        <ResendVerificationButton />
        <Link
          href={'/sign-in'}
          className="text-muted-foreground text-sm hover:text-blue-400 hover:underline hover:underline-offset-4"
        >
          Back to Sign-In?
        </Link>
      </div>
    </main>
  );
}

Build Resend Verification Button Component

File: src/app/(auth)/verify-email/resend-verification-button.tsx

'use client';

import { checkUserEmailPresent } from '@/app/actions/check-user-email';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { authClient } from '@/lib/auth-client';
import { useState } from 'react';

export const ResendVerificationButton = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [success, setSuccess] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [email, setEmail] = useState<string | null>('');

  async function resendVerificationEmail() {
    if (!email) return;

    setSuccess(null);
    setError(null);
    setIsLoading(true);

    const result = await checkUserEmailPresent(email);

    if (!result.success) {
      setError(result?.message ?? 'Some error occurred. Try again!');
      setIsLoading(false);
      return;
    }

    const { error } = await authClient.sendVerificationEmail({
      email,
      callbackURL: '/email-verified',
    });

    setIsLoading(false);

    if (error) {
      setError(error?.message ?? 'Something went wrong!');
    } else {
      setSuccess('Verification Email Sent Successfully!');
    }
  }

  return (
    <div className="max-w-md space-y-4">
      {success && (
        <div role="status" className="text-sm text-green-600">
          {success}
        </div>
      )}
      {error && (
        <div role="alert" className="text-sm text-rose-400">
          {error}
        </div>
      )}
      <Input
        placeholder="Enter email to verify"
        type="email"
        onChange={(e) => setEmail(e.target.value)}
      />
      <Button
        type="button"
        onClick={resendVerificationEmail}
        disabled={isLoading}
        className="w-full"
      >
        {isLoading ? 'Resending' : 'Resend'} Verification Email
      </Button>
    </div>
  );
};

Resend Verification Button Features:

  • shadcn UI Components Used:

    • Input - Email input field for user to enter their email address
    • Button - Action button with loading state during email sending
  • State Management:

    • isLoading - Controls button disabled state and text
    • success - Stores success message in green text
    • error - Stores error message in red text
    • email - Stores user input email address
  • On Button Click:

    1. Validates email is not empty
    2. Calls server action checkUserEmailPresent(email) to verify user exists
    3. Checks if email is already verified
    4. If valid, calls authClient.sendVerificationEmail() with email and callback URL
    5. Button text changes to "Resending" with disabled state
  • On Success:

    • Success message displayed in green: "Verification Email Sent Successfully!"
    • New verification email sent to user's inbox
    • Email contains link to /email-verified page
    • User can check their email and click verification link
    • Link expires in 5 minutes (Better Auth default)
  • On Error:

    • Error message displayed in red text below input
    • Common errors:
      • "No user found with this email address" - Email not registered
      • "Email is already verified" - User already completed verification
      • "Something went wrong!" - Network or server issues
    • User can correct email and try again
    • Loading state is cleared, button becomes clickable again

Create Email Verified Success Page

File: src/app/(auth)/email-verified/page.tsx

import { Button } from '@/components/ui/button';
import type { Metadata } from 'next';
import Link from 'next/link';

export const metadata: Metadata = {
  title: 'Email Verification | Autonode',
};

export default function EmailVerifiedPage() {
  return (
    <main className="flex h-screen w-screen flex-1 items-center justify-center px-4 text-center">
      <div className="space-y-6">
        <div className="space-y-2">
          <h1 className="text-2xl font-semibold">Email verified</h1>
          <p className="text-sm text-emerald-400">
            Your email has been verified successfully.
          </p>
        </div>
        <Button>
          <Link href="/dashboard">Go to dashboard</Link>
        </Button>
      </div>
    </main>
  );
}

Create Server Action for Email Validation

File: src/app/actions/check-user-email.ts

'use server';

import prisma from '@/lib/db';

export async function checkUserEmailPresent(email: string) {
  try {
    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      return {
        success: false,
        message: 'No user found with this email address.',
      };
    }

    if (user.emailVerified) {
      return {
        success: false,
        message: 'Email is already verified.',
      };
    }

    return {
      success: true,
      message: 'User found and email not verified.',
    };
  } catch (error) {
    return {
      success: false,
      message: 'An error occurred while checking email.',
    };
  }
}

Testing the Implementation

1. Start Development Server

npm run dev

2. Test Email & Password Flow

  1. Navigate to http://localhost:3000/sign-up
  2. Fill out the registration form
  3. Check your email for the verification link
  4. Click the verification link
  5. You'll be redirected to /email-verified
  6. Navigate to /dashboard to see your protected page

3. Test Social OAuth

  1. Navigate to http://localhost:3000/sign-in
  2. Click "Google" or "GitHub"
  3. Complete the OAuth flow
  4. You'll be redirected to /dashboard

4. Test Protected Routes

  1. Try accessing /dashboard without being logged in
  2. You should be redirected to /sign-in
  3. After logging in, you can access /dashboard

5. Test Email Verification Resend

  1. If you didn't receive the verification email
  2. Navigate to /verify-email
  3. Enter your email and click "Resend Verification Email"
  4. Check your inbox again

Troubleshooting

Common Issues & Solutions

Issue 1: Email Not Sending

Solution:

  • Check your SMTP credentials in .env
  • For Gmail, ensure you're using an App Password (not your regular password)
  • Check spam/junk folder
  • Verify MAIL_HOST is correct for your provider

Issue 2: Database Connection Error

Solution:

# Verify DATABASE_URL in .env
# Run migrations again
npx prisma migrate reset
npx prisma generate
npx prisma migrate dev

Issue 3: OAuth Not Working

Solution:

  • Verify OAuth credentials in .env
  • Check redirect URIs in Google/GitHub console:
    • Google: http://localhost:3000/api/auth/callback/google
    • GitHub: http://localhost:3000/api/auth/callback/github
  • Ensure social providers are configured in auth.ts

Issue 4: Session Not Persisting

Solution:

  • Clear browser cookies
  • Check BETTER_AUTH_SECRET is set in .env
  • Ensure cookies are enabled in browser
  • Verify nextCookies plugin is added to client config

Issue 5: TypeScript Errors

Solution:

# Regenerate Prisma types
npx prisma generate

# Restart TypeScript server in VS Code
# Press Ctrl+Shift+P and run "TypeScript: Restart TS Server"

Issue 6: Verification Email Expired

Solution:

  • Email verification links expire in 5 minutes (Better Auth default)
  • Use the resend functionality on /verify-email page
  • To change expiration time, modify Better Auth config:
// In src/lib/auth.ts
emailVerification: {
  expiresIn: 600, // 10 minutes in seconds
  // ... rest of config
}

Additional Resources

  • Better Auth Documentation
  • shadcn UI Components
  • Prisma Documentation
  • Next.js App Router
  • React Hook Form
  • Zod Validation

Conclusion

You now have a fully functional authentication system with:

  • Email & password authentication with validation
  • Social OAuth (Google & GitHub)
  • Email verification with custom templates
  • Protected routes
  • Beautiful shadcn UI forms
  • Type-safe database with Prisma
  • Comprehensive error handling

This implementation is production-ready and follows best practices for Next.js 14+ applications. You can extend it further by adding features like password reset, two-factor authentication, or additional OAuth providers.


Built with ❤️ using Better Auth, Next.js, and shadcn UI