Build Authentication Email Flows for a Next.js SaaS (with Notify)
Ship signup verification, password reset, and magic links in a Next.js App Router SaaS — Notify delivers the email, you own the tokens.
Every SaaS needs auth email. Not a newsletter. Not a template marketplace. Four boring messages that must land:
- Verify your email after signup
- Reset your password
- Optional magic-link sign-in
- Optional “new login” alert
This guide builds those flows for a Next.js App Router app. You store hashed tokens in Postgres (via whatever client you already use). Notify sends the HTML. No MCP, no visual builder — one fetch with an API key.
Architecture
Browser → Server Action / Route Handler → DB (hashed token)
→ Notify (email with raw token)
User clicks link → Route Handler → verify hash → session cookie
Notify’s job stops at delivery. Sessions, password hashes, and rate limits stay in your app.
Prerequisites
- Next.js App Router project
- A database (Prisma, Drizzle, Supabase — examples are SQL-shaped and framework-agnostic)
NOTIFY_API_KEYfrom Credentials- A verified domain for production
fromaddresses
NOTIFY_API_KEY=your_api_key_here
NEXT_PUBLIC_APP_URL=http://localhost:3000
Keep the API key server-only. Never import send helpers into Client Components.
Data model
create table users (
id uuid primary key default gen_random_uuid(),
email text unique not null,
password_hash text,
email_verified_at timestamptz,
created_at timestamptz default now()
);
create table auth_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid references users(id) on delete cascade,
email text, -- for magic links before a user row exists
type text not null check (type in ('verify', 'reset', 'magic')),
token_hash text not null,
expires_at timestamptz not null,
used_at timestamptz,
created_at timestamptz default now()
);
create index auth_tokens_hash_idx on auth_tokens (token_hash);
Shared email helper
// lib/notify.ts
export async function sendEmail(opts: {
to: string;
subject: string;
message: string;
}) {
const response = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.NOTIFY_API_KEY!
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
...opts
})
});
if (!response.ok) {
throw new Error(`Notify error: ${await response.text()}`);
}
return response.json();
}
During local rehearsal before domain verification, point at https://notify.cx/api/email/send/test or use the dashboard guided test. See Sandbox vs production.
Token helpers
// lib/auth-tokens.ts
import { createHash, randomBytes } from 'crypto';
export function createRawToken() {
return randomBytes(32).toString('hex');
}
export function hashToken(raw: string) {
return createHash('sha256').update(raw).digest('hex');
}
Always persist hashToken(raw). Email only the raw value.
1. Signup + email verification
After you create the user (password hashed with bcrypt/argon2), issue a verify token and send:
// lib/auth/send-verification.ts
import { createRawToken, hashToken } from '@/lib/auth-tokens';
import { sendEmail } from '@/lib/notify';
export async function sendVerificationEmail(user: {
id: string;
email: string;
}) {
const raw = createRawToken();
await db.authTokens.create({
userId: user.id,
type: 'verify',
tokenHash: hashToken(raw),
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24)
});
const url = `${process.env.NEXT_PUBLIC_APP_URL}/verify-email?token=${raw}`;
await sendEmail({
to: user.email,
subject: 'Verify your email',
message: `
<h1>Confirm your email</h1>
<p><a href="${url}">Verify email</a></p>
<p>This link expires in 24 hours.</p>
`
});
}
Verify route:
// app/api/auth/verify-email/route.ts
import { hashToken } from '@/lib/auth-tokens';
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get('token');
if (!token) return new Response('Missing token', { status: 400 });
const row = await findValidToken(hashToken(token), 'verify');
if (!row?.userId) return new Response('Invalid or expired', { status: 400 });
await markEmailVerified(row.userId);
await markTokenUsed(row.id);
return Response.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/login?verified=1`);
}
2. Password reset
Enumeration-safe forgot-password endpoint:
// app/api/auth/forgot-password/route.ts
import { createRawToken, hashToken } from '@/lib/auth-tokens';
import { sendEmail } from '@/lib/notify';
export async function POST(request: Request) {
const { email } = await request.json();
if (!email || typeof email !== 'string') {
return Response.json({ error: 'email is required' }, { status: 400 });
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
// allowForgotPasswordAttempt: e.g. Redis/Upstash — max 5 attempts / email / hour and / IP / hour
if (!(await allowForgotPasswordAttempt(ip, email))) {
return Response.json({ error: 'Too many requests' }, { status: 429 });
}
const user = await findUserByEmail(email);
if (user) {
const raw = createRawToken();
await db.authTokens.create({
userId: user.id,
type: 'reset',
tokenHash: hashToken(raw),
expiresAt: new Date(Date.now() + 1000 * 60 * 60)
});
const url = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${raw}`;
await sendEmail({
to: email,
subject: 'Reset your password',
message: `
<h1>Reset your password</h1>
<p><a href="${url}">Choose a new password</a></p>
<p>This link expires in one hour. If you did not request this, ignore this email.</p>
`
});
}
return Response.json({
ok: true,
message: 'If that email exists, we sent a reset link.'
});
}
Consume token, update password hash, mark token used, invalidate sessions. Full UI pattern: How to send password reset emails.
3. Magic link sign-in
export async function sendMagicLink(email: string) {
const raw = createRawToken();
await db.authTokens.create({
email,
type: 'magic',
tokenHash: hashToken(raw),
expiresAt: new Date(Date.now() + 1000 * 60 * 15)
});
const url = `${process.env.NEXT_PUBLIC_APP_URL}/auth/magic?token=${raw}`;
await sendEmail({
to: email,
subject: 'Your sign-in link',
message: `<p><a href="${url}">Sign in</a></p><p>Expires in 15 minutes.</p>`
});
}
On GET /auth/magic: validate hash, findOrCreateUserByEmail, create session cookie, mark token used. Short TTLs only.
More patterns (OTP subjects, Auth.js / Better Auth hooks): Magic links & OTP · Auth recipe.
4. Optional login alert
After a successful password login from a new device fingerprint, fire-and-forget:
await sendEmail({
to: user.email,
subject: 'New sign-in to Your App',
message: `<p>New sign-in at ${new Date().toISOString()} from IP ${ip}.</p><p>If this was not you, reset your password.</p>`
});
Keep it transactional and rare. Do not turn this into a marketing drip.
Plugging into Better Auth
If you use Better Auth instead of hand-rolled tokens, keep their token URL and swap only delivery:
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Verify your email',
message: `<p><a href="${url}">Verify email</a></p>`
});
}
Same idea for sendMagicLink / password reset callbacks.
Webhooks for auth mail that bounces
When you move to Pro/Scale, subscribe to bounce events and mark the address unusable for automated auth mail until the user updates it. See Webhooks. Free tier still has dashboard logs (48h retention).
Production checklist
- [ ] Verified sending domain (SPF/DKIM)
- [ ] Rate limits on signup, forgot-password, magic-link
- [ ] Hashed tokens, TTLs, single-use
- [ ] Generic responses on forgot-password
- [ ] HTTPS links only
- [ ] Password KDF (argon2/bcrypt) — never store plaintext
- [ ] Session invalidation after password reset
- [ ] Bounce handling once volume justifies webhooks
What we deliberately skipped
OAuth providers, passkeys, RBAC UI, and newsletter “onboarding sequences.” Those are separate products. Auth email is a send pipe plus careful token hygiene.
Ship it
- Add
lib/notify.tsand env key - Create
auth_tokens - Wire verify + reset
- Send a real message to yourself on a verified domain
- Watch logs
Notify pricing for this workload: Free 1,000 emails/mo, Pro $10 / 10,000 — enough for most early SaaS auth traffic. Pricing · Quick start