tutorial
How to Add OTP Verification to a Next.js App in 5 Minutes
Passwordless login is the new normal. Most SaaS apps in 2026 sign users in with either an email OTP or a magic link, and passwords are quietly being pushed out of onboarding flows across the board. This tutorial walks you through adding both to a Next.js 15 (App Router) project with two API calls — no codes to save in your database, no cron jobs to expire them, no wrestling with a mail provider.
Coming from Twilio Verify or Auth0? Read our Twilio Verify alternative comparison first for the trade-offs before you switch.
Why not just build your own OTP flow?
A one-time code sounds like a weekend project until you list what actually goes into it: making a code that's random enough that no one can guess it, hashing it before you save it, expiring it after a few minutes, capping how many tries each user gets, capping how many times an IP can hit your endpoint, picking an email provider, getting your sending domain trusted so mail lands in the inbox, dealing with bounces, and testing all of it in CI without blasting real inboxes. Every one of those steps has a subtle security or delivery gotcha hiding in it. A ready-made OTP verification API rolls that whole pile into one vendor and two HTTP calls, which is exactly what this tutorial shows.
What you'll build
- A login page that takes an email address and sends a 6-digit code.
- An OTP verification API route that checks the code and signs the user in.
- A Playwright test that runs the full flow against a sandbox inbox — no mocks.
Step 1 — Create an otpmagiclink project
- Sign up at otpmagiclink.com (free, no card).
- Create a project and copy its API key. Start with a sandbox project so nothing goes to real inboxes while you're building.
- Add it to
.env.local:
OTPMAGICLINK_API_KEY=sk_...
Step 2 — Send the OTP
Add a route that takes an email and asks otpmagiclink to make and send an OTP. The identifier can be any string (email, phone, user ID) — use whatever your product means by "this user".
// app/api/auth/send-otp/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { email } = await req.json();
const res = await fetch("https://otpmagiclink.com/api/v1/verifications", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTPMAGICLINK_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: email,
channel: "EMAIL",
kind: "OTP",
}),
});
const data = await res.json();
return NextResponse.json({ verificationId: data.id });
}
Step 3 — Check the OTP
When the user pastes the code from their email, send it back to otpmagiclink's /check endpoint. If the response status is VERIFIED, you know the email is real and you can sign the user in.
// app/api/auth/check-otp/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { verificationId, code } = await req.json();
const res = await fetch(
`https://otpmagiclink.com/api/v1/verifications/${verificationId}/check`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTPMAGICLINK_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ code }),
},
);
const data = await res.json();
if (data.status !== "VERIFIED") {
return NextResponse.json({ error: "Invalid code" }, { status: 400 });
}
// Sign the user in — create your session, JWT, whatever
return NextResponse.json({ ok: true, email: data.identifier });
}
That's the whole login loop. Two API calls. No code in your database. otpmagiclink handles expiration, retry limits, hashing, and rate limits.
Step 4 — Test it end-to-end (without email flake)
Your sandbox project has an inbox you can hit through the API, so your Playwright tests can read the same OTP a real user would see — without waiting on real email delivery or mocking anything out.
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
const API = "https://otpmagiclink.com/api/v1";
const KEY = process.env.OTPMAGICLINK_SANDBOX_KEY!;
test("user can log in with an OTP", async ({ page }) => {
const email = `user+${Date.now()}@example.com`;
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Send code" }).click();
// Fetch the sandbox inbox — same API call your real user hits
const inbox = await fetch(`${API}/sandbox/inbox/${email}`, {
headers: { Authorization: `Bearer ${KEY}` },
}).then((r) => r.json());
const otp = inbox.messages[0].otp;
await page.getByLabel("Code").fill(otp);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL("/dashboard");
});
This is the killer feature for CI. Your test suite hits the same API paths as production. No jest.mock(), no "we'll test that by hand", no scraping the Mailtrap web UI. Every push through your pipeline runs a real send-and-check round-trip against a real sandbox mailbox, and that's the only way to catch bugs in your login flow before real users hit them.
Common gotchas when building your own OTP
Even with a hosted API doing the hard part, there are a few Next.js-specific traps worth flagging:
- Don't call the verification API from a Server Component. Server Components run at render time and cache hard. Send and check calls belong in Route Handlers or Server Actions, where you know exactly when they run.
- Never trust the client's email on
check. Always pass theverificationIdyou got back fromcreate, not the raw email. Thatidties the check to the exact code you sent, so an attacker can't try random codes against random emails. - Don't save the OTP on your side. The whole point of a stateless API like this is that the code only lives in otpmagiclink and the user's inbox. If you catch yourself writing it to a session, cookie, or database column, stop and think again.
- Rate-limit at your route too. otpmagiclink caps how many times each user can request a code, but you should still add a simple per-IP cap in Next.js middleware so bots can't probe your login page.
Going to production
When you're ready to ship:
- Create a production project (not sandbox).
- In its delivery settings, plug in your own email sender — Resend, Postmark, SendGrid, or SMTP. Your domain, your sender name, your good standing with inbox providers. This bring-your-own-provider setup is one of the biggest wins over the big incumbent APIs that lock you into their delivery vendor.
- Swap
OTPMAGICLINK_API_KEYto the production key in your deployment env. - Optionally, hook up a webhook endpoint to fire your product analytics on
verification.verified. Webhooks are the cleanest way to feed OTP events into other systems — Segment, PostHog, your CRM — without stuffing tracking calls into every login route. - Set a spending cap and per-project rate limits in your dashboard so a retry loop gone wild or a leaked key can't rack up a surprise bill.
What about magic links?
Same API, one flag change. Send kind: "MAGIC_LINK" instead of "OTP", and the user gets a click-to-sign-in link in their email. Redirects, single-use, and expiration are all handled by otpmagiclink. This is one of the things that's awkward on Twilio Verify — they don't have a built-in magic-link feature, so you'd have to glue together a transactional email provider and your own token store.
FAQ
Do I need a database table for OTP codes?
No. otpmagiclink stores the hashed code, expiration, and try counter on its side. On your Next.js app you only need whatever session store you were already using (Auth.js, iron-session, a custom JWT — all fine).
Does it work with Auth.js (NextAuth)?
Yes. Use the Credentials provider and put the check-otp call inside the authorize function. On VERIFIED, return the user object; on failure, return null. Auth.js handles the session cookie, otpmagiclink handles the check.
Does it work with the Pages Router?
Yes. The examples above use Route Handlers (App Router), but the same API calls work as-is from pages/api/* handlers. The only difference is req/res instead of Request/NextResponse.
How do I customize the email template?
Each project has a template you can edit in the dashboard — the sender name, subject line, and body. Placeholders like {{code}} and {{magic_link}} get swapped in at send time. If you need more control, you can bring your own email provider (Resend/Postmark) and render the template on your side, sending only the raw code through otpmagiclink.
What happens on retries or wrong codes?
Each check call bumps a try counter. After the max (default 5), the code is locked and the user has to ask for a new one. This is handled for you — the response status just flips to EXPIRED, and you show "code expired, request a new one" in the UI.
Is there a free tier for local dev?
Yes. Sandbox projects are free forever and use the API inbox instead of real email delivery, which is exactly what you want for local dev and CI. When you deploy to prod, spin up a separate production project with your real sender.
Where to go next
- Compare against the big player: Twilio Verify alternative for Next.js teams.
- Start a free project on otpmagiclink.com and drop the snippets above into your app.
- Read the API reference for the extras: custom code length, MAGIC_LINK vs OTP, and how to verify webhook signatures.
Try otpmagiclink free
Sandbox project + full API access, no credit card required.