React Email14 min read

Better Auth email templates: verification, reset, and magic links

Build Better Auth email templates with React Email and Resend in Next.js: verification, password reset, magic links, tested code, and an AI-builder prompt.

R

React Emails Pro

October 19, 2026

You can customize Better Auth email templates with React Email and send them through Resend. Better Auth supplies the verification, reset, or sign-in URL; your template controls what the recipient reads. Connect the component to the correct server callback and keep the sending key on the server.

This walkthrough connects verification, password reset, and magic link emails to Resend in a Next.js app on Vercel. You will have a small free template to adapt, a sender that checks provider errors, and the client calls that finish each flow. If an AI builder maintains your app, use the implementation prompt near the end.

One account. Three different emails.

Choose a flow to see its trigger, message, and destination.

01 / Trigger

Someone creates an account

emailVerification.sendVerificationEmail

02 / After the click

Confirms ownership of the email address

A welcome email can follow successful verification. It cannot replace this step.

Go to the implementation

Verify your email for Acme

Acme <accounts@mail.acme.example>

Acme

One step before you start

Confirm this address to finish setting up your Acme account.

Verify email address

This link expires in 60 minutes. If you did not request it, you can ignore this email.

Illustrative preview. Expiry matches this guide's configuration.

Better Auth joined Vercel on July 7, 2026, and version 1.7 arrived on August 17. This guide checks the current APIs as of October 19, 2026. It focuses on your app's outgoing auth emails; an existing authentication migration should follow the project's upgrade guide separately.

1

Prepare the sender and database

Verify a sending domain, set server secrets, and keep your existing database adapter.

2

Build one template with three messages

Share the layout while giving verification, reset, and sign-in their own copy.

3

Wire the Better Auth callbacks

Pass each generated URL through unchanged and keep delivery alive after the response.

4

Finish the click and test the failure paths

Build the reset form, check expired links, and confirm that each successful action changes the account.


1. Prepare your app and sending domain

Start with an app that already uses Better Auth, or complete its installation guide first, including database tables and migrations. The example below uses PostgreSQL through pg. If your app already has Prisma, Drizzle, or another supported adapter, keep it and merge the email options into your existing configuration. Replacing a working database connection adds nothing to the email setup.

In Resend, open Domains, add your sending domain, and publish the DNS records it gives you. Wait for verification before testing with an external recipient. Use an address on that domain for the sender, such as Acme <accounts@mail.example.com>. The domain hosting your app and the domain sending its email can be different. Both need to be configured for their own job.

Terminal
npm install better-auth resend @react-email/components @react-email/render @vercel/functions pg server-only
npm install -D @types/pg

Add these settings to your server environment and to the matching deployment environment. Keep the existing auth secret if the app already has one. For a new app, generate a strong secret following Better Auth's installation instructions. Never give these values a NEXT_PUBLIC_ prefix.

.env.local (replace every placeholder)
BETTER_AUTH_URL=https://app.example.com
BETTER_AUTH_SECRET=your-existing-or-new-strong-secret
DATABASE_URL=your-postgres-connection-string
RESEND_API_KEY=your-resend-api-key
AUTH_EMAIL_FROM="Acme <accounts@mail.example.com>"

For local development, set BETTER_AUTH_URLto your actual local origin, including the port. Restart the server after changing environment variables. In production, use the public HTTPS origin. A localhost link in a customer's email is usually an environment mistake, not a template rendering problem.


2. Build a React Email template for each action

Use one layout and three explicit messages. Verification proves control of an address. Password reset starts a password change. A magic link signs someone in. A generic "Confirm" button makes those actions harder to understand, especially when someone receives more than one email during setup.

This starter intentionally has no product promotion. The recipient needs to recognize the app, understand the request, and complete one action. Put your real product name in the subject and body together. Keep the visible expiry tied to the server configuration rather than copying a default from another tutorial.

emails/auth-email.tsx
import {
  Body, Button, Container, Head, Heading, Html,
  Link, Preview, Text,
} from "@react-email/components";

export type AuthEmailKind = "verification" | "reset" | "magic-link";

export const authCopy = {
  verification: {
    subject: "Verify your email for Acme",
    heading: "One step before you start",
    body: "Confirm this address to finish setting up your Acme account.",
    action: "Verify email address",
  },
  reset: {
    subject: "Reset your Acme password",
    heading: "Choose a new password",
    body: "We received a request to reset the password for your Acme account.",
    action: "Reset password",
  },
  "magic-link": {
    subject: "Your sign-in link for Acme",
    heading: "Sign in to Acme",
    body: "Use this one-time link to sign in to your Acme account.",
    action: "Sign in",
  },
} satisfies Record<AuthEmailKind, {
  subject: string; heading: string; body: string; action: string;
}>;

export function AuthEmail({ kind, actionUrl, expiresInMinutes }: {
  kind: AuthEmailKind;
  actionUrl: string;
  expiresInMinutes: number;
}) {
  const copy = authCopy[kind];
  return (
    <Html lang="en">
      <Head />
      <Preview>{copy.subject}</Preview>
      <Body style={{ backgroundColor: "#f5f5f4", fontFamily: "Arial, sans-serif" }}>
        <Container style={{ maxWidth: "480px", margin: "32px auto", padding: "32px", backgroundColor: "#ffffff" }}>
          <Text style={{ color: "#57534e", fontSize: "14px" }}>ACME</Text>
          <Heading style={{ color: "#1c1917", fontSize: "24px" }}>{copy.heading}</Heading>
          <Text style={{ color: "#44403c", lineHeight: "24px" }}>{copy.body}</Text>
          <Button href={actionUrl} style={{ backgroundColor: "#1c1917", color: "#ffffff", padding: "14px 20px", borderRadius: "6px" }}>
            {copy.action}
          </Button>
          <Text style={{ color: "#57534e", fontSize: "14px" }}>
            This link expires in {expiresInMinutes} minutes.
            If you did not request it, you can ignore this email.
          </Text>
          <Text style={{ color: "#57534e", fontSize: "12px" }}>
            Button not working? Open this link:<br />
            <Link href={actionUrl} style={{ color: "#44403c", wordBreak: "break-all" }}>{actionUrl}</Link>
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

The fallback link uses the same URL as the button. It is useful when a mail client fails to render the button, but it also contains a credential. Exclude auth pages from session replay and analytics that capture query strings. Avoid third-party assets on the reset page while the token is present, and set a restrictive referrer policy there.

Keep auth emails short enough to understand with images disabled. A logo is optional. The app name, action, expiry, and an unexpected-request explanation should still work as text.

Send HTML and plain text, and check the result

Put the provider integration in a server-only helper. Here we render HTML once and derive the plain text from it. The origin check catches a misconfigured auth host before a broken link reaches a user. It allows the app origin from your environment, which is separate from the email's sending domain.

lib/auth-email.tsx
import "server-only";
import { createHash } from "node:crypto";
import { render, toPlainText } from "@react-email/render";
import { Resend } from "resend";
import { AuthEmail, authCopy, type AuthEmailKind } from "@/emails/auth-email";

export function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error("Missing server setting: " + name);
  return value;
}

const resend = new Resend(requiredEnv("RESEND_API_KEY"));
const appOrigin = new URL(requiredEnv("BETTER_AUTH_URL")).origin;

export async function sendAuthEmail(input: {
  kind: AuthEmailKind;
  to: string;
  url: string;
  expiresInMinutes: number;
}): Promise<void> {
  const url = new URL(input.url);
  if (
    !["https:", "http:"].includes(url.protocol) ||
    url.origin !== appOrigin || url.username || url.password
  ) throw new Error("Unexpected auth link origin");

  // Preserve the exact URL, including the token and callback parameters.
  const html = await render(AuthEmail({
    kind: input.kind,
    actionUrl: input.url,
    expiresInMinutes: input.expiresInMinutes,
  }));
  const key = createHash("sha256")
    .update(JSON.stringify([input.kind, input.to, input.url]))
    .digest("hex");
  const { data, error } = await resend.emails.send({
    from: requiredEnv("AUTH_EMAIL_FROM"),
    to: input.to,
    subject: authCopy[input.kind].subject,
    html,
    text: toPlainText(html),
  }, { idempotencyKey: key });

  if (error || !data?.id) throw new Error("Auth email provider rejected send");
}

Resend's Next.js guide returns both data and an error from the send call. Check that result: a resolved promise alone does not prove the message was accepted. Even an accepted message is not proof of inbox placement. Use provider events and an actual test mailbox to inspect delivery.

The hashed idempotency key represents one action URL for one recipient. A retry of that send uses the same key; a newly issued URL produces a new one. Resend retains idempotency keys for 24 hours. If you add retries, preserve the original message payload too. A queue must stop retrying once the auth link is too old to be useful.


3. Connect verification, reset, and magic link callbacks

Merge these options into lib/auth.ts. Better Auth's email callbacks give you the recipient and action URL. Pass that URL to the sender unchanged. Do not replace it with /dashboard, strip its query parameters, or generate your own token in the template.

The durations below are deliberate choices for this example: 60 minutes for verification, 30 for reset, and 10 for magic links. They are used both in the template and in Better Auth. Change each pair together to match your product's policy.

lib/auth.ts
import "server-only";
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import { waitUntil } from "@vercel/functions";
import { Pool } from "pg";
import { requiredEnv, sendAuthEmail } from "@/lib/auth-email";

// Use your existing pool/adapter instead if the app already has one.
const pool = new Pool({ connectionString: requiredEnv("DATABASE_URL") });
const minutes = { verification: 60, reset: 30, magicLink: 10 } as const;

function deliver(input: Parameters<typeof sendAuthEmail>[0]): void {
  waitUntil(sendAuthEmail(input).catch(() => {
    // Replace with your error monitor. Never log tokens or whole URLs.
    console.error("auth_email_failed", { kind: input.kind });
  }));
}

export const auth = betterAuth({
  database: pool,
  baseURL: requiredEnv("BETTER_AUTH_URL"),
  secret: requiredEnv("BETTER_AUTH_SECRET"),
  emailVerification: {
    sendOnSignUp: true,
    sendOnSignIn: true,
    autoSignInAfterVerification: false,
    expiresIn: minutes.verification * 60,
    sendVerificationEmail: async ({ user, url }) => {
      deliver({ kind: "verification", to: user.email, url,
        expiresInMinutes: minutes.verification });
    },
  },
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    resetPasswordTokenExpiresIn: minutes.reset * 60,
    revokeSessionsOnPasswordReset: true,
    sendResetPassword: async ({ user, url }) => {
      deliver({ kind: "reset", to: user.email, url,
        expiresInMinutes: minutes.reset });
    },
  },
  plugins: [
    magicLink({
      expiresIn: minutes.magicLink * 60,
      disableSignUp: true,
      sendMagicLink: async ({ email, url }) => {
        deliver({ kind: "magic-link", to: email, url,
          expiresInMinutes: minutes.magicLink });
      },
    }),
    nextCookies(),
  ],
});

The magic link plugin requires a server plugin and a matching client plugin, shown below. We set disableSignUp: true so passwordless sign-in is for existing accounts. Make that an explicit product decision. If you allow magic links to create accounts, add onboarding and test that path too.

One detail from our local check: with disableSignUp enabled, requesting a magic link for an unknown address still called the sender. Following that link refused account creation. Treat the setting as a registration rule, not a promise that unknown addresses receive no email.

Current magic link documentation says verification consumes the token on its first attempt. The old allowedAttempts option is deprecated and ignored. Increasing it is not a fix for a link that has already been used.

On Vercel, waitUntil lets delivery continue after the auth response, with errors sent to your monitor. It shares the function's timeout; it is not a durable retry queue. On another host, use its supported background task mechanism or enqueue the work. A bare unawaited promise can be interrupted when a serverless request ends.

Mount the handler using the Next.js integration. The nextCookies() plugin stays last in the plugin list for server action cookie handling. If this route already exists in your app, update the shared auth configuration rather than creating a second auth instance.

app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const runtime = "nodejs";
export const { GET, POST } = toNextJsHandler(auth);

What is connected now

Better Auth owns the token and account state. The callbacks choose the message. React Email creates the body, and Resend accepts or rejects the send. Changing a template can change the presentation; it cannot repair a missing callback or an incorrect app URL.


4. Finish the browser flows, including password reset

Create the client once and use it from your forms. The functions below return a message for your UI to render. Show a pending state and disable repeat submissions while each request runs. Also handle a network failure in the form's error boundary or submit handler so a lost connection does not leave a spinner running forever.

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

export const authClient = createAuthClient({
  plugins: [magicLinkClient()],
});

// Call from your sign-up form; show the returned error next to the form.
export async function signUp(name: string, email: string, password: string) {
  const { error } = await authClient.signUp.email({
    name, email, password, callbackURL: "/sign-in?verified=1",
  });
  return error
    ? { ok: false, message: error.message ?? "Unable to sign up. Try again." }
    : { ok: true, message: "Check your inbox to verify your email." };
}

export async function requestReset(email: string) {
  const { error } = await authClient.requestPasswordReset({
    email,
    redirectTo: new URL("/reset-password", window.location.origin).href,
  });
  return error
    ? { ok: false, message: "Unable to send right now. Try again shortly." }
    : { ok: true, message: "If an account exists, a reset email is on its way." };
}

export async function requestMagicLink(email: string) {
  const { error } = await authClient.signIn.magicLink({
    email, callbackURL: "/app", errorCallbackURL: "/sign-in?link=invalid",
  });
  return error
    ? { ok: false, message: "Unable to send right now. Try again shortly." }
    : { ok: true, message: "If your account is eligible, check your inbox." };
}

This configuration requires verification for email-and-password sign-in and deliberately leaves automatic sign-in after verification off. The verification link returns to your sign-in page. Do not treat the verified=1 query parameter as authorization: only the validated server session can grant access.

A reset email needs a real /reset-password page. Better Auth sends the browser there with a token, or an error when the link is invalid. Build a form with a new-password field and call this function on submission. The password reset API validates the token when the new password is submitted.

lib/finish-password-reset.ts
"use client";
import { authClient } from "@/lib/auth-client";

// Call this from the password form's submit handler. Render its message.
export async function finishPasswordReset(newPassword: string): Promise<{
  ok: boolean; message: string;
}> {
  const params = new URLSearchParams(window.location.search);
  const token = params.get("token");
  if (!token || params.has("error")) {
    return { ok: false, message: "This link is invalid. Request a new reset email." };
  }
  const { error } = await authClient.resetPassword({ newPassword, token });
  if (error) {
    return { ok: false, message: "Password not changed. Check the password requirements or request a new link." };
  }
  window.history.replaceState(null, "", window.location.pathname);
  return { ok: true, message: "Password changed. Sign in with your new password." };
}

Give an invalid link a visible way to request another email. On success, the example clears the token from the address bar and asks the person to sign in again. The server configuration also revokes existing sessions after a successful reset. Test that behavior with a second browser session, not just the page displaying the success message.

Keep reset responses generic whether or not an account exists. The sender runs in the background so its response time is not added directly to the request. This is only one part of account enumeration protection. Preserve Better Auth's checks and rate limits, and test your own forms and any proxy endpoints you add.


Find the failure before changing the template

What you seeCheck firstNext action
No message in ResendWhether the expected callback ranCheck the route, trigger settings, and server environment
Provider rejects the sendAPI key, verified sender, and provider errorFix delivery settings before editing HTML
Email arrives with a localhost URLBETTER_AUTH_URL in that deploymentSet the public origin, restart, and request a fresh email
Reset link opens a missing pageredirectTo and /reset-passwordBuild the form and submit its token to resetPassword
Magic link fails on first human clickExpiry, earlier requests, tracking, and security scannersInspect the link path and use a fresh request; consider email OTP
Click succeeds but app still denies accessSession cookie and server session validationCheck cookies, origins, and your protected route

A symptom narrows the investigation. It does not prove the cause.

Turn off click tracking for authentication links so the email provider does not wrap them in another URL. Security tools may still inspect links before the recipient does. Repeated failures in a corporate mailbox deserve a test with that actual mail system. For users who keep hitting this problem, evaluate Better Auth's email OTP plugin and a code-entry UI. Do not make a bearer link reusable to hide the symptom.

A welcome email belongs after the account reaches the state your product needs, such as completed verification. Use an event or job with its own deduplication rule for that send. Sending it inside the verification email callback repeats it whenever someone requests a new verification link. Our SaaS onboarding guide covers the messages that follow account setup.

A prompt for Cursor, Claude Code, or v0

Ask the builder to inspect before editing. A working app may already have a database adapter, auth route, or provider helper worth keeping. Supply secrets through your host's environment settings, never through a public form or client-side code.

Connect my Better Auth emails

Cursor, Claude Code, v0, or your existing AI builder

Inspect this app's installed Better Auth version, database adapter, auth route, and email provider before changing code. Keep the existing adapter and auth secret.

Connect verification, password reset, and magic link emails to server-side React Email templates and the existing provider (Resend if none is configured). Use each URL supplied by Better Auth unchanged. Give each flow its own subject, button label, and expiry matching the server config.

Require verification for password sign-in. Keep automatic sign-in after verification off and make magic links existing-account only. Mount the Next.js handler and add the matching magic link client plugin. Implement /reset-password with invalid-token handling and a form that calls resetPassword. Revoke sessions after reset.

Use the host's supported background task mechanism, check provider errors, and report failures without logging tokens or full URLs. Keep reset responses generic and retain rate limits. Disable provider click tracking for auth links.

Show the files changed. Test signup, verification, fresh and expired links, password reset, session revocation, and magic link replay. Tell me which checks need a real mailbox or deployed environment. Do not claim delivery or inbox placement from a mocked provider test.

Test account behavior as well as the email

For this guide we type-checked the snippets against Better Auth 1.7.11, Resend 6.34.1, and React Email components 1.0.12. We rendered all three template variants and checked their action URLs and plain text output. Local tests also exercised the auth callbacks with an in-memory database and a mocked sender. These checks do not establish real inbox placement or behavior in your deployment.

  • Register a test account, verify it, and confirm that password sign-in is blocked before verification.
  • Request a reset for an existing address and an unknown address. Compare the visible responses.
  • Change the password and confirm the old password and existing sessions stop working.
  • Use a magic link once, then try it again. Test an expired link and an unknown account too.
  • Force a provider error and confirm your error monitor records the flow without credentials.
  • Send to real external mailboxes, including a corporate inbox if your customers use one. Test the complete click on mobile.

Preview the message in Gmail, Outlook, and Apple Mail before relying on the layout. Check dark mode, a long app name, and images disabled. For a broader release check, use the React Email and Resend production checklist. Finish by following a fresh reset link from your own mailbox and signing in with the new password.

Does Better Auth include an email sending service?

You connect your own provider through server callbacks. Better Auth creates the auth URLs; your provider delivers the messages. A custom React Email template supplies the HTML and plain text presentation.

Can I use custom React Email templates with Better Auth?

Yes. Render your component inside your server-side sender and call it from sendVerificationEmail, sendResetPassword, or the magic link plugin's sendMagicLink callback. Preserve the URL passed to that callback.

Why does my reset email open a page without changing the password?

The link opens your password reset page. That page needs a form which submits the new password and token to authClient.resetPassword. A successful email send does not implement that form.

Can one template handle verification, reset, and magic links?

Yes, with explicit variants for the subject, explanation, action label, and expiry. Share the layout, but give each action its correct meaning. A magic link signs someone in; email verification confirms ownership of an address.

Key takeaway

Before shipping your Better Auth email templates:

  • Connect each auth action to the correct callback and keep the generated URL intact.
  • Match the message's expiry to the configured token lifetime.
  • Complete the destination page, especially the password reset form.
  • Monitor sends, test failed and repeated actions, then verify the flow in real mailboxes.
R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates