DEV Community

Cover image for Testing NextAuth Magic Links in Playwright Without a Local SMTP Server
zerodrop
zerodrop

Posted on

Testing NextAuth Magic Links in Playwright Without a Local SMTP Server

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);
});
Enter fullscreen mode Exit fullscreen mode

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!);
Enter fullscreen mode Exit fullscreen mode

That's the entire email handling. No parser. No fixture server.

Setup

npm install zerodrop-client @playwright/test
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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" })],
});
Enter fullscreen mode Exit fullscreen mode
// 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");
});
Enter fullscreen mode Exit fullscreen mode

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)