A few weeks ago, I built a website using NextAuth with Resend for Authentication with a magic link . Everything was set-up in just a few hours. I've found this combination to work really well, so I wanted to share it!
Magic Link implementation
Here’s what we’ll cover step-by-step:
-
Form Creation: Capture user input with React 19 hooks (useFormState, useTransition) and Server Actions.
-
Validation: Use Server Action and Zod to validate Form.
-
Authentication: Configure NextAuth with Prisma as the database adapter and Resend to handle email part.
-
Magic Link: Use Resend for the magic link, and React-Email for the design.
1. Create a new route and form.
To enable users to request a magic link, I’ve set up a dedicated route that accepts their email through a form.
In my app directory, i like to create (auth)
folder by using routes group
, thus will handle all the authentication.
Here’s how the folder structure looks:
app/
└── (auth)/
└── login/
└── page.tsx
This page will serve as a login with the following code.
"use client";
import { sendLink } from "@/lib/action/action";
import { DEFAULT_STATE_ACTION } from "@/lib/action/action.type";
import { useActionState, useTransition } from "react";
export default function LoginPage() {
const [state, formAction] = useActionState(sendLink, DEFAULT_STATE_ACTION);
const [isPending, startTransition] = useTransition();
const submitAction = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
startTransition(() => {
const formData = new FormData(event.currentTarget);
formAction(formData);
});
};
return (
<form
className="flex flex-col gap-4 w-full max-w-md"
onSubmit={submitAction}
>
<div className="flex flex-col gap-2">
<input
type="email"
id="email"
name="email"
placeholder="Enter your email"
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && (
<p className="text-green-500">{state.success}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600"
>
{isPending ? "Sending..." : "Send Magic Link"}
</button>
</form>
);
}
I'm using two hooks from React 19, useTransition is used to handle the form submission without blocking the UI, and useActionState manages the form’s state, such as errors or success messages.
The form is very simple, on submit, it call the sendLink
action. We also display success or error messages based on the form's state.
2. Create the action.
With the form in place, i'll create a server action to handle data validation, and send the magic link. We'll use Zod for data validation, and integrate NextAuth5 and Resend to handle authentication and email delivery.
The action return either a success or error state, depending on the outcome.
lib/actions/action.tsx;
"use server";
import { signIn } from "@/auth";
import { z } from "zod";
import { DEFAULT_STATE } from "./action.type";
const authSchema = z.object({
email: z.string().email("Invalid email address"),
});
export const sendLink = async (
formState: DEFAULT_STATE,
formData: FormData
) => {
const validated = authSchema.safeParse({
email: formData.get("email"),
});
if (!validated.success) {
return <DEFAULT_STATE>{
success: "",
error: validated.error.errors[0]?.message,
};
}
try {
await signIn("resend", {
email: validated.data.email,
redirect: false,
callbackUrl: "/",
});
return <DEFAULT_STATE>{
error: "",
success: "Magic link sent! Check your email.",
};
} catch (error) {
return <DEFAULT_STATE>{
success: "",
error: "Failed to send magic link. Please try again.",
};
}
};
That's it! signIn function manages everything through NextAuth and redirect to /
after the user will login through the link.
3. Setup Next-Auth with Prisma adapter and Resend.
With NextAuth, as per the Documentation, the following files are mandatory.
./auth.ts
import NextAuth from "next-auth"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
})
./middleware.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
./app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
However, i'll update auth.ts to specify Prisma as the database adapter and Resend as the email provider. I won’t go into the details of the Prisma schema here—please refer to documentation for that.
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import Resend from "next-auth/providers/resend";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { sendVerificationRequest } from "./lib/send-verification-request";
import prisma from "./prisma/db";
const authOptions: NextAuthConfig = {
adapter: PrismaAdapter(prisma) as Adapter,
providers: [
Resend({
from: "Acme onboarding@resend.dev",
sendVerificationRequest: async (params) => {
await sendVerificationRequest(params);
},
}),
],
callbacks: {},
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
secret: process.env.AUTH_SECRET,
} satisfies NextAuthConfig;
export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);
Note that, i've specified "/login" to use my own login page instead of default NextAuth login page.
The last step is the configuration of Resend and the sendVerificationRequest
that NextAuth call.
4. Configure Resend and Email content
I'm using the following utilitie function to get Resend.
./lib/getResend.ts
import { Resend } from 'resend'
let resendPromise: Resend | null;
const getResend = () => {
if (!resendPromise) {
resendPromise = new Resend(process.env.RESEND_API_KEY);
}
return resendPromise;
};
export default getResend;
The sendVerificationRequest which is based on Resend documentation.
import { EmailTemplate } from "./email-template";
import getResend from "./getResend";
interface VerificationRequestParams {
identifier: string;
provider: {
from: string;
};
url: string;
}
export async function sendVerificationRequest(
params: VerificationRequestParams
) {
const resend = getResend();
const { url } = params;
try {
const { data, error } = await resend.emails.send({
from: 'Acme onboarding@resend.dev',
to: ['delivered@resend.dev'],
subject: "Hello World",
react: EmailTemplate({ magicLink: url }),
});
if (error) {
return Response.json({ error }, { status: 500 });
}
return Response.json(data);
} catch (error) {
return Response.json({ error }, { status: 500 });
}
}
And finally, for the content of the email, i'll be using React Email which make this really easier and they have a lot of templates.
./lib/email-template.tsx
They even made tailwind works!
import {
Button,
Head,
Heading,
Html,
Preview,
Section,
Tailwind,
} from "@react-email/components";
interface EmailTemplateProps {
magicLink: string;
}
export const EmailTemplate: React.FC<Readonly<EmailTemplateProps>> = ({
magicLink,
}) => (
<Html>
<Head />
<Preview>Log in with this magic link. 🎉</Preview>
<Tailwind>
<Heading className="mx-0 my-[30px] p-0 text-center text-3xl font-bold text-black">
🎉Your magic link🎉
</Heading>
<Section className="my-[32px] text-center">
<Button
className="text-md rounded bg-[#6C63FF] px-5 py-3 text-center font-semibold text-white no-underline"
href={magicLink}
>
Click here to log in
</Button>
</Section>
</Tailwind>
</Html>
);
I've updated the HomePage to show if the users is authenticate or not;
import { auth } from "@/auth";
export default async function Home() {
const session = await auth();
if (!session) {
return <h1>Hello world</h1>;
}
return (
<h1 className="text-2xl font-bold">Welcome {session?.user?.email}!</h1>
);
}
That's it, here is a little video to show the end result.