Better Auth verification and reset email

Better Auth does not send email, it calls the function you give it. Leave sendVerificationEmail out and verification silently never happens. This page fills in every send callback with a real implementation, in one auth.ts.

Setup

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

  2. 02

    Put it in .env

    EMAILSSH_API_KEY, server-side only. auth.ts is a server module and must never be imported from a client component.

  3. 03

    Fill in the send callbacks

    sendResetPassword under emailAndPassword, sendVerificationEmail under emailVerification, and sendMagicLink if you use that plugin. Each receives the url already built for you.

  4. 04

    Turn on requireEmailVerification

    Otherwise unverified accounts can sign in, and the verification email is decorative.

Install
npm install better-auth @emails.sh/sdk
.env
BETTER_AUTH_SECRET=a_long_random_string
BETTER_AUTH_URL=https://acme.com
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"

The auth config

lib/auth.ts (better-auth 1.x)

The auth config
import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins';
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });
const FROM = process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>';

async function send(to: string, subject: string, heading: string, url: string, cta: string) {
	await mail.send({
		from: FROM,
		to: [to],
		subject,
		html: `<p>${heading}</p>
<p><a href="${url}">${cta}</a></p>
<p>If you did not request this, ignore this email. The link expires in one hour.</p>`,
		text: `${heading}\n\n${cta}: ${url}\n\nIf you did not request this, ignore this email.`
	});
}

export const auth = betterAuth({
	emailAndPassword: {
		enabled: true,
		// Without this, an account works before the address is proven, and the
		// verification email is decoration.
		requireEmailVerification: true,
		sendResetPassword: async ({ user, url }) => {
			await send(user.email, 'Reset your Acme password', 'Reset your password.', url, 'Choose a new password');
		}
	},

	emailVerification: {
		sendOnSignUp: true,
		autoSignInAfterVerification: true,
		sendVerificationEmail: async ({ user, url }) => {
			await send(user.email, 'Confirm your email', 'Confirm your email to finish signing up.', url, 'Confirm email');
		}
	},

	plugins: [
		magicLink({
			sendMagicLink: async ({ email, url }) => {
				await send(email, 'Your sign-in link', 'Here is your sign-in link.', url, 'Sign in');
			}
		})
	]
});

Mounting the handler

app/api/auth/[...all]/route.ts (Next.js App Router)

Mounting the handler
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/lib/auth';

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

Resending verification from the client

components/resend-verification.tsx

Resending verification from the client
'use client';

import { useState } from 'react';
import { createAuthClient } from 'better-auth/react';

const authClient = createAuthClient();

export function ResendVerification({ email }: { email: string }) {
	const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');

	async function resend() {
		setState('sending');
		await authClient.sendVerificationEmail({
			email,
			callbackURL: '/dashboard'
		});
		setState('sent');
	}

	return (
		<button onClick={resend} disabled={state !== 'idle'}>
			{state === 'sent' ? 'Sent, check your inbox' : 'Resend verification email'}
		</button>
	);
}

Worth knowing

01

A missing callback fails silently

Better Auth calls whatever you provided. If sendVerificationEmail is absent, nothing throws and nothing sends, so signup looks fine and the user never gets the link. That is the first thing to check.

02

The url is already built, do not reconstruct it

The callback receives a complete, signed URL including the token and callbackURL. Building your own from the token means a link that fails verification.

03

Do not swallow errors in the callback

A try/catch that logs and continues makes Better Auth believe the mail went out. Let it throw so the sign-up response reflects the failure.

04

auth.ts is server only

It holds BETTER_AUTH_SECRET and your API key. Import it from route handlers and server actions, and use createAuthClient from better-auth/react on the client.

Questions

Why is Better Auth not sending verification emails?

sendVerificationEmail is not defined, or sendOnSignUp is false. Both are required for a send on signup, and neither produces an error when missing.

How do I customise the Better Auth email template?

The callback is yours. Render any HTML you like and pass it as html, as long as the url ends up in a link the user can click.

Does this work outside Next.js?

Yes. auth.ts is framework agnostic. Only the handler mount changes: there are adapters for SvelteKit, Nuxt, Hono, Remix, and plain Node.

Can users sign in before verifying?

Only if requireEmailVerification is false. Set it to true and sign-in is refused until the link is clicked.

The rest of the API

POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.