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:
- Node.js (LTS version) installed.
- A package manager like npm, yarn, or pnpm (we'll use
pnpm
in this guide). - 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.
- 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:
- Start from scratch by following these guides:
- Or, clone the starter project:
git clone -b feat-anonymous-1 https://github.com/Daanish2003/better_auth_nextjs.git
Navigate to the project directory and install dependencies:
pnpm install
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"
If you're using Docker for PostgreSQL, start the container:
docker compose up -d
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}`,
});
}
}),
]
})
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;
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;
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;
Step 4: Run your application:
Start your development server:
pnpm dev
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)