DEV Community

Daanish2003
Daanish2003

Posted on

1

Magic link authentication using Better_Auth, Next.js, Shadcn, Prisma Resend, Tailwindcss

In this guide, we will implement Magic Link Authentication for a modern web application using a secure and developer-friendly stack. This feature enables users to authenticate by clicking a unique, one-time link sent to their email, eliminating the need for traditional passwords. By the end of this tutorial, your application will provide a seamless and secure login experience using magic links, ensuring both user convenience and robust authentication practices.

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-anonymous-1 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

Step 1: Update auth.ts file and auth-client.ts file

Open auth.ts file in your project repository and then add the magic link plugin to the plugin array of betterAuth.

Note: Resend has been already setup at Email verification blog. If you want to integrate Resend please visit Resend or visit my pervious blog Email Verification Guide blog to setup

// src/lib/auth.ts

import { magicLink } from "better-auth/plugins"

export const auth = betterAuth({
   appName: "better_auth_nextjs",
   // other provider options
   plugins: [
      // other plugin options
      magicLink({
      disableSignUp: true, // Disable using magic link at signup
      sendMagicLink: async ({email, url}) => {
        await resend.emails.send({
          from: "Acme <onboarding@resend.dev>",
          to: email,
          subject: "Magic Link",
          html: `Click the link to login into your account: ${url}`,
        });
      }
    }),
   ]
})
Enter fullscreen mode Exit fullscreen mode

Then open the auth-client.ts file and update the code by adding the magicLinkClient() in your plugin array.

// src/lib/auth-client.ts
// other imports
import { magicLinkClient} from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react";

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

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

Step 2: Update the login-schema.ts file

open login-schema.ts file in your project folder and update the schema file with following code below

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

// This schema file is used for both login using traditional signin (email/username + password)
// And Magic link signin using only email

// Schema for traditional sign-in (email/username + password)
const TraditionalSignInSchema = z.object({
  emailOrUsername: z
    .string()
    .min(1, "Email or Username is required")
    .refine(
      (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || value.length >= 3,
      {
        message: "Must be a valid email or username",
      }
    ),
  password: z.string().nonempty("Password is required"),
});

// Schema for magic link sign-in (email only)
const MagicLinkSignInSchema = z.object({
  emailOrUsername: z
    .string()
    .min(1, "Email is required")
    .email("Must be a valid email"),
});
// Combined schema for dynamic sign-in
const SignInSchema = z.union([TraditionalSignInSchema, MagicLinkSignInSchema]);
export default SignInSchema;
Enter fullscreen mode Exit fullscreen mode

Step 3: Update the sign-in component

Open sign-in.tsx file in your project and update the whole component with below code

// components/auth/sign-in.tsx
"use client";

import React, { useState } 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 { useAuthState } from "@/hooks/useAuthState";
import { signIn } from "@/lib/auth-client";

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

// Import the schemas (adjusted to match likely export)
import SignInSchema from "@/helpers/zod/login-schema";
import { Mail, Mailbox } from "lucide-react";
import { requestOTP } from "@/helpers/auth/request-otp";

const SignIn = () => {
    // State to manage the current sign-in method (traditional vs magic link)
    const [signInMethod, setSignInMethod] = useState<'traditional' | 'magicLink'>('traditional');

    // Router instance for navigation
    const router = useRouter();

    // Authentication state hooks for managing feedback and loading state
    const { 
        error, 
        success, 
        loading, 
        setSuccess, 
        setError, 
        setLoading, 
        resetState 
    } = useAuthState();

    // Extract schema options for traditional and magic link sign-in methods
    const TraditionalSignInSchema = SignInSchema.options[0];
    const MagicLinkSignInSchema = SignInSchema.options[1];

    // Dynamically determine the current schema based on the selected sign-in method
    const currentSchema = signInMethod === 'traditional' 
        ? TraditionalSignInSchema 
        : MagicLinkSignInSchema;

    // Initialize form handling with the appropriate schema and default values
    const form = useForm<z.infer<typeof currentSchema>>({
        resolver: zodResolver(currentSchema),
        defaultValues: {
            emailOrUsername: "",
            ...(signInMethod === 'traditional' ? { password: "" } : {}),
        },
    });

    // Form submission handler
    const onSubmit = async (values: z.infer<typeof currentSchema>) => {
        resetState(); // Reset any existing error or success messages
        setLoading(true); // Indicate that a request is in progress

        try {
            if (signInMethod === 'magicLink') {
                // Handle magic link sign-in
                await signIn.magicLink(
                    { email: values.emailOrUsername },
                    {
                        onRequest: () => setLoading(true),
                        onResponse: () => setLoading(false),
                        onSuccess: () => {
                            setSuccess("A magic link has been sent to your email.");
                        },
                        onError: (ctx) => {
                            setError(ctx.error.message || "Failed to send magic link.");
                        },
                    }
                );
            } else {
                // Handle traditional sign-in
                const signInValues = values as z.infer<typeof TraditionalSignInSchema>;

                // Determine if the input is an email or username
                const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(signInValues.emailOrUsername);

                if (isEmail) {
                    // Email-based login
                    await signIn.email(
                        { 
                            email: signInValues.emailOrUsername, 
                            password: signInValues.password 
                        },
                        {
                            onRequest: () => setLoading(true),
                            onResponse: () => setLoading(false),
                            onSuccess: async(ctx) => {
                                // Handle two-factor authentication if required
                                if(ctx.data.twoFactorRedirect) {
                                    const response = await requestOTP();
                                    if(response?.data) {
                                        setSuccess("OTP has been sent to your email");
                                        router.push("/two-factor");
                                    } else if (response?.error) {
                                        setError(response.error.message);
                                    }
                                } else {
                                    setSuccess("Logged in successfully.");
                                    router.replace("/");
                                }
                            },
                            onError: (ctx) => {
                                setError(
                                    ctx.error.message || "Email login failed. Please try again."
                                );
                            },
                        }
                    );
                } else {
                    // Username-based login
                    await signIn.username(
                        { 
                            username: signInValues.emailOrUsername, 
                            password: signInValues.password 
                        },
                        {
                            onRequest: () => setLoading(true),
                            onResponse: () => setLoading(false),
                            onSuccess: async(ctx) => {
                                // Handle two-factor authentication if required
                                if(ctx.data.twoFactorRedirect) {
                                    const response = await requestOTP();
                                    if(response?.data) {
                                        setSuccess("OTP has been sent to your email");
                                        router.push("/two-factor");
                                    } else if (response?.error) {
                                        setError(response.error.message);
                                    }
                                } else {
                                    setSuccess("Logged in successfully.");
                                    router.replace("/");
                                }
                            },
                            onError: (ctx) => {
                                setError(
                                    ctx.error.message || "Username login failed. Please try again."
                                );
                            },
                        }
                    );
                }
            }
        } catch (err) {
            console.error(err);
            setError("Something went wrong. Please try again.");
        } finally {
            setLoading(false); // Reset loading state
        }
    };

    return (
        <CardWrapper
            cardTitle="Sign In"
            cardDescription="Enter your details 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>
                                    {signInMethod === 'magicLink' ? 'Email' : 'Email or Username'}
                                </FormLabel>
                                <FormControl>
                                    <Input
                                        disabled={loading}
                                        type="text"
                                        placeholder={
                                            signInMethod === 'magicLink'
                                                ? "Enter your email"
                                                : "Enter email or username"
                                        }
                                        {...field}
                                    />
                                </FormControl>
                                <FormMessage />
                            </FormItem>
                        )}
                    />

                    {/* Password Field (only for traditional sign-in) */}
                    {signInMethod === 'traditional' && (
                        <FormField
                            control={form.control}
                            name="password"
                            render={({ field }) => (
                                <FormItem>
                                    <FormLabel>Password</FormLabel>
                                    <FormControl>
                                        <Input
                                            disabled={loading}
                                            type="password"
                                            placeholder="********"
                                            {...field}
                                        />
                                    </FormControl>
                                    <FormMessage />
                                    <Link
                                        href="/forgot-password"
                                        className="text-xs underline ml-60"
                                    >
                                        Forgot Password?
                                    </Link>
                                </FormItem>
                            )}
                        />
                    )}

                    {/* Error & Success Messages */}
                    <FormError message={error} />
                    <FormSuccess message={success} />

                    {/* Submit Button */}
                    <Button disabled={loading} type="submit" className="w-full">
                        {signInMethod === 'magicLink' ? "Send Magic Link" : "Login"}
                    </Button>

                    {/* Social Buttons */}
                    <div className="flex justify-between mt-4">
                        <SocialButton provider="google" icon={<FcGoogle />} label="" />
                        <SocialButton provider="github" icon={<FaGithub />} label="" />
                        <Button
                            type="button"
                            className="w-20"
                            onClick={() => setSignInMethod(
                                signInMethod === 'traditional' ? 'magicLink' : 'traditional'
                            )}
                        >
                            {signInMethod === 'traditional'
                                ? <Mailbox />
                                : <Mail />}
                        </Button>
                        <AnonymousButton />
                    </div>
                </form>
            </Form>
        </CardWrapper>
    );
};

export default SignIn;
Enter fullscreen mode Exit fullscreen mode

Step 4: Run your application:

Start your development server:

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Navigate to your sign-in route and try login using magic link

Conclusion

Congratulations! 🎉 You’ve successfully implemented Magic Link Authentication in your application. This secure and user-friendly feature allows users to log in effortlessly using a one-time email link, removing the need for traditional passwords. By offering a seamless and password-free authentication experience, your app is now more accessible and secure, catering to modern user expectations while maintaining robust security standards.

Blog Links:

Anonymous Login using BetterAuth: https://dev.to/daanish2003/anonymous-login-using-betterauth-nextjs-prisma-shadcn-5334

Username and Password auth using BetterAuth: https://dev.to/daanish2003/username-and-password-authentication-with-betterauth-nextjs-prisma-shadcn-and-tailwindcss-1hc6

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

Billboard image

Monitoring as code

With Checkly, you can use Playwright tests and Javascript to monitor end-to-end scenarios in your NextJS, Astro, Remix, or other application.

Get started now!

Top comments (0)

Cloudinary image

Zoom pan, gen fill, restore, overlay, upscale, crop, resize...

Chain advanced transformations through a set of image and video APIs while optimizing assets by 90%.

Explore

👋 Kindness is contagious

Dive into an ocean of knowledge with this thought-provoking post, revered deeply within the supportive DEV Community. Developers of all levels are welcome to join and enhance our collective intelligence.

Saying a simple "thank you" can brighten someone's day. Share your gratitude in the comments below!

On DEV, sharing ideas eases our path and fortifies our community connections. Found this helpful? Sending a quick thanks to the author can be profoundly valued.

Okay