DEV Community

Daanish2003
Daanish2003

Posted on

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

Top comments (0)