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.

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

  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