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:

  1. Verify your email after signup
  2. Reset your password
  3. Optional magic-link sign-in
  4. 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_KEY from Credentials
  • A verified domain for production from addresses
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.

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

  1. Add lib/notify.ts and env key
  2. Create auth_tokens
  3. Wire verify + reset
  4. Send a real message to yourself on a verified domain
  5. 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

Frequently asked questions

Which auth emails does a SaaS need?

Email verification, password reset, optional magic link, and optional new-login alerts. Keep them transactional and one-action each.

How do I send auth email from Next.js with Notify?

From a Server Action or Route Handler, fetch POST /api/email/send with NOTIFY_API_KEY. Store hashed tokens in your database — Notify only delivers. Next.js guide.

Should auth tokens live in Notify?

No. You own cryptography and session logic. Notify is the delivery pipe for the HTML you render.

Where are the detailed recipes?

Password reset, magic link / OTP, auth verification recipe.