DEV Community

Daanish2003
Daanish2003

Posted on

Username and Password Authentication with Better_Auth, Next.js, Prisma, Shadcn, and TailwindCSS

In this guide, we will implement Username and Password Authentication for a modern web application using a robust and secure stack of tools. This feature will complement existing Email and Password Authentication in the application. By the end of this tutorial, your app will allow users to log in with either their username or email, with optional OTP-based Two-Factor Authentication (2FA) for enhanced security.

Tech Stack

Here’s the technology stack we’ll be using:

  • Better_Auth v1: A lightweight and extensible TypeScript authentication library.
  • Next.js: A powerful React framework for building server-rendered applications.
  • Prisma: A modern ORM for efficient and type-safe database interaction.
  • ShadCN: A utility-first component library for rapid UI development.
  • TailwindCSS: A popular CSS framework for building modern user interfaces.
  • Resend: A reliable email service for sending OTPs.

Prerequisites

Before proceeding, ensure you have the following ready:

  1. Node.js (LTS version) installed.
  2. A package manager like npm, yarn, or pnpm (we'll use pnpm in this guide).
  3. A PostgreSQL database instance (local or hosted, such as Supabase or PlanetScale).
    • If you're working locally, Docker is a great way to set this up.
  4. Familiarity with TypeScript, Next.js, and Prisma.

Cloning the Starter Project:

This guide builds upon functionality such as Email-Password Authentication and Email Verification. You can:

git clone -b feat-two-factor https://github.com/Daanish2003/better_auth_nextjs.git  
Enter fullscreen mode Exit fullscreen mode

Navigate to the project directory and install dependencies:

pnpm install  
Enter fullscreen mode Exit fullscreen mode

Setup

1. Configure the .env File

Create a .env file in the root of your project and add these configurations:

# Authentication settings
BETTER_AUTH_SECRET="your-secret-key" # Replace with a secure key
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"

# Database settings
POSTGRES_PASSWORD="your-password"
POSTGRES_USER="your-username"
DATABASE_URL="postgresql://your-username:your-password@localhost:5432/mydb?schema=public"

# Resend API Key
RESEND_API_KEY="your-resend-api-key"
Enter fullscreen mode Exit fullscreen mode

If you're using Docker for PostgreSQL, start the container:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Note:

Before we move on to implementing username and password authentication in your full stack application. I would like to inform you that in this blog we will implementing username and password authentication with existing Email and password authentication. So that user could able login using either email or username.

Step 1: Update schema.prisma file

Open schema.prismafile in your project folder and then update the usermodel in schema by adding the username column with optional type string to it.

model User {
  id               String      @id @default(cuid())
  name             String
  email            String
  emailVerified    Boolean     @default(false)
  image            String?
  createdAt        DateTime    @default(now())
  updatedAt        DateTime    @updatedAt
  twoFactorEnabled Boolean     @default(false)
  // Add only username column with type of string and ignore other columns if you already implemented user model
  username         String?
  isAnonymous      Boolean?
  Session          Session[]
  Account          Account[]
  TwoFactor        TwoFactor[] // if you implemented 2FA add this column

  @@unique([email])
  @@map("user")
}
Enter fullscreen mode Exit fullscreen mode

Then, Generate and migrate the prisma file using command below

pnpx prisma generate
pnpx prisma migrate dev --name username
Enter fullscreen mode Exit fullscreen mode

Step 2: Update auth.ts and auth-client.tsfile

Open auth.ts file in your project repository and then add the username()plugin into plugin array of betterAuth function.

// src/lib/auth.ts
import { betterAuth } from "better-auth"
import { username } from "better-auth/plugins"

const auth = betterAuth({
    // other config options
    plugins: [ 
        // other plugins
        username() 
    ] 
})
Enter fullscreen mode Exit fullscreen mode

Then, Open auth-client.ts file in your project repository and add usernameClient() plugin function to the array of plugins

// src/lib/auth-client.ts
import { createAuthClient } from "better-auth/client"
import { usernameClient } from "better-auth/client/plugins"

export const authClient = createAuthClient({
    baseURL: process.env.NEXT_PUBLIC_APP_URL,
    plugins: [ 
        usernameClient() 
    ] 
})

export const {
    signIn,
    signOut,
    signUp,
    useSession
} = authClient;
Enter fullscreen mode Exit fullscreen mode

Step 3: Update login-schema.ts and signup-schema.ts file

Open login-schema.tsfile in your project repository and update the schema with following code below.

// src/helpers/zod/login-schema.ts
import { z } from "zod";

const LoginSchema = z
  .object({
    // checks if the input given by the user is email or username
    emailOrUsername: z
      .string()
      .min(1, { message: "Email or username is required" }),
    password: z
      .string()
      .min(8, { message: "Password must be at least 8 characters long" })
      .max(20, { message: "Password must be at most 20 characters long" }),
  })
  .refine(
    (data) =>
      // checks if the email or username is valid
      /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.emailOrUsername) || /^[a-zA-Z0-9_.]+$/.test(data.emailOrUsername),
    {
      message: "Provide a valid email or username",
      path: ["emailOrUsername"],
    }
  );

export default LoginSchema
Enter fullscreen mode Exit fullscreen mode

Then, Open the signup-schema.ts file in your project repository and added the username field and its type in zod schema.

// src/helpers/zod/signup-schema.ts
export const SignupSchema = z
  .object({
    name: z
      .string()
      .min(2, { message: "Minimum 2 characters are required" })
      .max(20, { message: "Maximum of 20 characters are allowed" }),
    email: z
      .string()
      .email({ message: "Invalid email address" })
      .min(1, { message: "Email is required" }),
    password: z
      .string()
      .min(8, { message: "Password must be at least 8 characters long" })
      .max(20, { message: "Password must be at most 20 characters long" }),
    // Added the username field to check its type 
    username: z
      .string()
      .min(3, { message: "Username must be at least 3 characters long" })
      .max(25, { message: "Username must be at most 25 characters long" })
      .regex(/^[a-zA-Z0-9_.]+$/, { message: "Username can only contain letters, numbers, underscores, and dots" }),
  })
Enter fullscreen mode Exit fullscreen mode

Step 4: Create generateUsername file

Create a generate-username.ts inside the helper/auth/ folder and paste the code from below

// helpers/auth/generate-username.ts
export const generateUsername = (name: string) => {
    const randomNumbers = Math.floor(1000 + Math.random() * 9000); // Generate a random 4-digit number
    return `${name.replace(/\s+/g, '').toLowerCase()}${randomNumbers}`;
};
Enter fullscreen mode Exit fullscreen mode

GenerateUsernamefunction is used to generate random username from the given name of the user.

Step 5: Update SignIn component and Signup component

SignUp Component:

// src/components/auth/sign-in.tsx
"use client"
import React from 'react'
import CardWrapper from '../card-wrapper'
import FormError from '../form-error'
import { FormSuccess } from '../form-success'
import { FcGoogle } from 'react-icons/fc'
import SocialButton from './social-button'
import { FaGithub } from 'react-icons/fa'
import { useAuthState } from '@/hooks/useAuthState'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../ui/form'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import { SignupSchema } from '@/helpers/zod/signup-schema'
import { signUp } from '@/lib/auth-client'
import { generateUsername } from '@/helpers/auth/generate-username'

const SignUp = () => {
    const { error, success, loading, setLoading, setError, setSuccess, resetState } = useAuthState();

    const form = useForm<z.infer<typeof SignupSchema>>({
        resolver: zodResolver(SignupSchema),
        defaultValues: {
            name: '',
            email: '',
            password: '',
            username: '', // Added username field
        }
    })

    const onSubmit = async (values: z.infer<typeof SignupSchema>) => {
        try {
            await signUp.email({
                name: values.name,
                email: values.email,
                password: values.password,
                username: values.username, // Add the username fields
                callbackURL: '/'
            }, {
                onResponse: () => {
                    setLoading(false)
                },
                onRequest: () => {
                    resetState()
                    setLoading(true)
                },
                onSuccess: () => {
                    setSuccess("Verification link has been sent to your mail")
                },
                onError: (ctx) => {
                    setError(ctx.error.message);
                },
            });
        } catch (error) {
            console.error(error)
            setError("Something went wrong")
        }

    }

    return (
        <CardWrapper
            cardTitle='SignUp'
            cardDescription='Create an new account'
            cardFooterLink='/signin'
            cardFooterDescription='Already have an account?'
            cardFooterLinkTitle='Signin'
        >
            <Form {...form}>
                 {/* Make changes to only name field of the form*/}
                <form className='space-y-4' onSubmit={form.handleSubmit(onSubmit)}>
                    <FormField
                        control={form.control}
                        name="name"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>Name</FormLabel>
                                <FormControl>
                                    <Input
                                        disabled={loading}
                                        type="text"
                                        placeholder='john'
                                        {...field} 
                                        onChange={(e) => {
                                            field.onChange(e); // Update form state
                                            const username = generateUsername(e.target.value);
                                            form.setValue('username', username); // Auto-fill username
                                        }}
                                        />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />
                    {/* All other exisitng formfields like email and password */}
                </form>
            </Form>
        </CardWrapper>
    )
}

export default SignUp
Enter fullscreen mode Exit fullscreen mode

Add username logic to SignUp:

  • Auto-generate usernames based on the user's name.

SignIn Component

"use client";

import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useRouter } from "next/navigation";
import Link from "next/link";

import CardWrapper from "../card-wrapper";
import FormError from "../form-error";
import { FormSuccess } from "../form-success";
import { FcGoogle } from "react-icons/fc";
import { FaGithub } from "react-icons/fa";

import SocialButton from "./social-button";
import LoginSchema from "@/helpers/zod/login-schema";
import { useAuthState } from "@/hooks/useAuthState";
import { signIn } from "@/lib/auth-client";
import { requestOTP } from "@/helpers/auth/request-otp";

import {
    Form,
    FormControl,
    FormField,
    FormItem,
    FormLabel,
    FormMessage,
} from "../ui/form";
import { Input } from "../ui/input";
import { Button } from "../ui/button";

const SignIn = () => {
    const router = useRouter();
    // handles error, success, loading and resetState of a form
    const { error, success, loading, setSuccess, setError, setLoading, resetState } = useAuthState();

    const form = useForm<z.infer<typeof LoginSchema>>({
        resolver: zodResolver(LoginSchema),
        defaultValues: {
            emailOrUsername: "", // update the field email to emailOrUsername
            password: "",
        },
    });

    // Check if the value is an email
    const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

    const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
        const { emailOrUsername, password } = values;

        // Determine if the input is an email or username
        const isEmailInput = isEmail(emailOrUsername);

        try {
            // resetState of a form after resubmit
            resetState();
            setLoading(true);

            if (isEmailInput) {
              // if user has given email and its true then signin using email and password
              // also send 2FA code to the user's email, if enabled
                await signIn.email(
                    { email: emailOrUsername, password },
                    {
                        onRequest: () => setLoading(true),
                        onResponse: () => setLoading(false),
                        onSuccess: async (ctx) => {
                            if (ctx.data.twoFactorRedirect) {
                                const res = await requestOTP(); // request otp for 2fa
                                if (res?.data) {
                                    setSuccess("OTP has been sent to your email");
                                    router.push("two-factor");
                                } else if (res?.error) {
                                    setError(res.error.message);
                                }
                            } else { // if didn't enable 2FA show loggedIn message
                                setSuccess("Logged in successfully");
                                router.replace("/");
                            }
                        },
                        onError: (ctx) => {
                            const errorMessage =
                                ctx.error.status === 403
                                    ? "Please verify your email address"
                                    : ctx.error.message;
                            setError(errorMessage);
                        },
                    }
                );
            } else {
                // if user has given username then signin using username and password
                // also send 2FA code to the user's email, if enabled
                await signIn.username(
                    { username: emailOrUsername, password },
                    {
                        onRequest: () => setLoading(true),
                        onResponse: () => setLoading(false),
                        onSuccess: async (ctx) => {
                            if (ctx.data.twoFactorRedirect) {
                                const res = await requestOTP();
                                if (res?.data) {
                                    setSuccess("OTP has been sent to your email");
                                    router.push("two-factor");
                                } else if (res?.error) {
                                    setError(res.error.message);
                                }
                            } else {
                                setSuccess("Logged in successfully");
                                router.replace("/");
                            }
                        },
                        onError: (ctx) => {
                            const errorMessage =
                                ctx.error.status === 403
                                    ? "Please verify your email address"
                                    : ctx.error.message;
                            setError(errorMessage);
                        },
                    }
                );
            }
        } catch (err) { // catch the error
            console.error(err);
            setError("Something went wrong. Please try again.");
        } finally { // set the loading to false after submitting the form
            setLoading(false);
        }
    };

    return (
        <CardWrapper
            cardTitle="Sign In"
            {/* change the cardDescription for email to email or password */}
            cardDescription="Enter your email or username below to login to your account"
            cardFooterDescription="Don't have an account?"
            cardFooterLink="/signup"
            cardFooterLinkTitle="Sign up"
        >
            <Form {...form}>
                <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
                    {/* Email or Username Field */}
                    <FormField
                        control={form.control}
                        name="emailOrUsername"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>Email or Username</FormLabel>
                                <FormControl>
                                    <Input
                                        disabled={loading}
                                        type="text" // update the type from email to text
                                        placeholder="Enter email or username" // update the placeholder
                                        {...field}
                                    />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />
                    {/* All other exisitng formfields like password and form message */}
                </form>
            </Form>
        </CardWrapper>
    );
};

export default SignIn;
Enter fullscreen mode Exit fullscreen mode

Enable login with username or email in SignIn:

  • Update the email field to emailOrUsername.
  • Use a utility function to determine whether the input is an email or username.

Step 6: Run your application:

Start your development server:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Navigate to your sign-up route and sign-in route and try sign-up the user first with email, name and password and then try using using email or username.

Conclusion

Congratulations! 🎉 You’ve successfully implemented a Username and Password Authentication system in your application. Your users can now log in with either their email or username, making the experience more flexible and user-friendly.

Blog Links:

Two Factor Authentication using BetterAuth: https://dev.to/daanish2003/two-factor-authentication-using-betterauth-nextjs-prisma-shadcn-and-resend-1b5p

Forgot and ResetPassword using BetterAuth: https://dev.to/daanish2003/forgot-and-reset-password-using-betterauth-nextjs-and-resend-ilj

Email Verification Blog: https://dev.to/daanish2003/email-verification-using-betterauth-nextjs-and-resend-37gn

Email And Password with Better_Auth: https://dev.to/daanish2003/email-and-password-auth-using-betterauth-nextjs-prisma-shadcn-and-tailwindcss-hgc

OAuth Blog: https://dev.to/daanish2003/oauth-using-betterauth-nextjs-prisma-shadcn-and-tailwindcss-45bp

Reference Links:

Better_Auth Docs: https://www.better-auth.com/

pnpm Docs: https://pnpm.io/

Docker Docs: https://docs.docker.com/

Prisma Docs: https://www.prisma.io/docs/getting-started

Shadcn Docs: https://ui.shadcn.com/

Next.js Docs: https://nextjs.org/

Tailwindcss Docs: https://tailwindcss.com/

Github repository: https://github.com/Daanish2003/better_auth_nextjs

Top comments (0)