The most common approach to testing NextAuth magic links in Playwright looks like this:
import smtpTester from 'smtp-tester';
import { load as cheerioLoad } from 'cheerio';
test.beforeAll(() => {
mailServer = smtpTester.init(4025);
});
test('magic link login', async ({ page }) => {
// ...
const { email } = await mailServer.captureOne('test@example.com', { wait: 1000 });
const $ = cheerioLoad(email.html);
const emailLink = $('#magic-link').attr('href');
await page.goto(emailLink);
});
This requires:
- A local SMTP server running as a test fixture
- Cheerio to parse the HTML email body
- A hardcoded email address shared across tests
- A local Next.js app pointing at
localhost:4025
None of this works in CI without significant setup. And the Cheerio parsing breaks the moment NextAuth changes its email template.
There's a simpler way.
NextAuth Email Provider + ZeroDrop
ZeroDrop catches emails at Cloudflare's edge. email.magicLink is auto-extracted before your test reads it — no Cheerio, no HTML parsing, no local SMTP server.
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
await page.goto(email.magicLink!);
That's the entire email handling. No parser. No fixture server.
Setup
npm install zerodrop-client @playwright/test
No API key. No signup. No environment variables for ZeroDrop.
NextAuth config
Standard NextAuth Email Provider setup with Resend (or any provider):
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import Resend from "next-auth/providers/resend";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Resend({
from: "noreply@yourdomain.com",
}),
],
pages: {
signIn: "/login",
verifyRequest: "/check-email",
},
});
export { handler as GET, handler as POST };
Testing magic link sign-in
import { test, expect } from "@playwright/test";
import { ZeroDrop } from "zerodrop-client";
const mail = new ZeroDrop();
test("NextAuth magic link sign-in", async ({ page }) => {
// Generate a unique inbox — no network request
const inbox = mail.generateInbox();
// Request a magic link
await page.goto("/login");
await page.fill('input[name="email"]', inbox);
await page.click('button[type="submit"]');
// NextAuth redirects to verify request page
await expect(page).toHaveURL("/check-email");
// ZeroDrop catches the magic link email at the edge
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: { hasMagicLink: true },
});
// magicLink auto-extracted — no Cheerio, no regex, no HTML parsing
expect(email.magicLink).toBeTruthy();
expect(email.magicLink).toContain('/api/auth/callback/resend');
// Navigate to the magic link — NextAuth creates the session
await page.goto(email.magicLink!);
// Assert user is signed in
await expect(page).toHaveURL("/dashboard");
await expect(page.locator('text=Sign out')).toBeVisible();
});
Testing that the magic link is single-use
NextAuth invalidates magic link tokens after first use. Test that:
test("magic link is single-use", async ({ page, context }) => {
const inbox = mail.generateInbox();
await page.goto("/login");
await page.fill('input[name="email"]', inbox);
await page.click('button[type="submit"]');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
const magicLink = email.magicLink!;
// First use — should sign in successfully
await page.goto(magicLink);
await expect(page).toHaveURL("/dashboard");
// Second use — token already consumed, should show error
const page2 = await context.newPage();
await page2.goto(magicLink);
// NextAuth shows an error page for invalid/consumed tokens
await expect(page2.locator('text=Sign in')).toBeVisible();
await expect(page2).not.toHaveURL("/dashboard");
});
Testing magic link expiry
NextAuth tokens expire after 24 hours by default. Test that expired tokens are rejected:
test("expired magic link is rejected", async ({ page }) => {
const inbox = mail.generateInbox();
await page.goto("/login");
await page.fill('input[name="email"]', inbox);
await page.click('button[type="submit"]');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
// Tamper with the token to simulate expiry
const expiredLink = email.magicLink!.replace(
/token=[^&]+/,
'token=expired-token-xyz'
);
await page.goto(expiredLink);
// NextAuth shows an error for invalid tokens
await expect(page.locator('text=Sign in')).toBeVisible();
});
Filtering when multiple emails land
If your app sends a welcome email alongside the magic link, filter to get the right one:
const email = await mail.waitForLatest(inbox, {
timeout: 15000,
filter: {
from: 'noreply@yourdomain.com',
subject: 'Sign in',
hasMagicLink: true,
},
});
All string filters are case-insensitive partial matches.
Why not use smtp-tester?
The standard smtp-tester approach has several problems in CI:
1. Requires reconfiguring NextAuth for tests
NextAuth needs to point at localhost:4025 in test mode. That means different config for test vs production — a gap that can hide real email delivery bugs.
2. Cheerio parsing breaks with template changes
$('#magic-link').attr('href') assumes a specific HTML structure. NextAuth occasionally changes its email template. When it does, your test silently returns undefined and fails in confusing ways.
3. Shared inbox causes race conditions
test@example.com is shared across all tests. Parallel runs pollute each other's inboxes.
4. Doesn't work in GitHub Actions without Docker
smtp-tester binds to a local port. In CI, NextAuth can't reach it unless you set up Docker networking or run everything in the same container.
ZeroDrop has none of these problems. NextAuth sends to a real ZeroDrop inbox. The email is caught at the edge. email.magicLink is there when your test reads it.
Parallel test runs — no collisions
generateInbox() runs locally with no network request. Each worker gets a unique inbox automatically:
test.describe.configure({ mode: "parallel" });
test("user A login", async ({ page }) => {
const inbox = mail.generateInbox(); // unique inbox
// ...
});
test("user B login", async ({ page }) => {
const inbox = mail.generateInbox(); // different inbox, no collision
// ...
});
GitHub Actions CI
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
No services: block. No SMTP config. No Docker. ZeroDrop catches emails from wherever NextAuth sends them.
Auth.js v5 (NextAuth v5)
The same pattern works with Auth.js v5:
// auth.ts
import NextAuth from "next-auth";
import Resend from "next-auth/providers/resend";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [Resend({ from: "noreply@yourdomain.com" })],
});
// test
test("Auth.js magic link", async ({ page }) => {
const inbox = mail.generateInbox();
await page.goto("/login");
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
const email = await mail.waitForLatest(inbox, { timeout: 15000 });
// Auth.js callback URL pattern
expect(email.magicLink).toContain('/api/auth/callback/resend');
await page.goto(email.magicLink!);
await expect(page).toHaveURL("/dashboard");
});
Conclusion
Testing NextAuth magic links doesn't require a local SMTP server, Cheerio, or a hardcoded test email address.
ZeroDrop catches the real email NextAuth sends, email.magicLink is auto-extracted at Cloudflare's edge, and your test navigates to it. No parser. No fixture. No Docker.
Free to use. No signup required. Works in CI out of the box.
→ zerodrop.dev · npm · docs
Top comments (0)