# Better Auth verification and reset email

Wire Better Auth verification, password reset, and magic link email to emails.sh. Complete auth config with every send callback filled in.

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. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **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.
1. **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.
1. **Turn on requireEmailVerification** Otherwise unverified accounts can sign in, and the verification email is decorative.

## Install

```bash
npm install better-auth @emails.sh/sdk
```

## .env

```bash
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)

```ts
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)

```ts
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

```ts
'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

### 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.

### 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.

### 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.

### 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.


Docs: https://emails.sh/docs.md