Email OTP in Next.js with the App Router
Add 6-digit email code sign-in to Next.js: two Route Handlers, the otp_id in an httpOnly cookie, a client form with one-time-code autofill, and every error to handle.
Email OTP in Next.js is two server calls: one sends a 6-digit code to the address the user typed, one checks the code they type back. This post builds it with the App Router, using Route Handlers, an httpOnly cookie and the SignOps email OTP API, which makes the code, sends it from your domain, and checks it.
The flow
| Step | Where | What happens |
|---|---|---|
| 1. User types an email | Client component | Form posts to /api/otp/send |
| 2. Send the code | Route Handler | POST /v1/otp/send, otp_id saved in an httpOnly cookie |
| 3. User types the 6 digits | Client component | Form posts to /api/otp/verify |
| 4. Check the code | Route Handler | POST /v1/otp/verify with the cookie’s otp_id |
| 5. Sign in | Route Handler | Your session is created only when valid is true |
Setup
Put your secret key in .env.local. Use the test key (sk_test_...) while building: test codes go to the test addresses you list in the dashboard, so you can try the whole flow before adding DNS records.
# .env.local
SIGNOPS_KEY=sk_test_...
No NEXT_PUBLIC_ prefix: the key must never reach the browser. One small helper keeps the calls in one place:
// lib/signops.ts
import "server-only";
const API = "https://api.signops.dev/v1";
export async function signops(path: string, body: Record<string, unknown>) {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SIGNOPS_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
cache: "no-store",
});
return { status: res.status, retryAfter: res.headers.get("retry-after"), data: await res.json() };
}
server-only (npm i server-only) makes the build fail if a client component imports this file.
Send the code
// app/api/otp/send/route.ts
import { cookies } from "next/headers";
import { signops } from "@/lib/signops";
export async function POST(request: Request) {
const { email } = await request.json();
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const r = await signops("/otp/send", { email, app_name: "Corner Market", ip });
if (r.status !== 200) {
return Response.json(
{ error: r.data.error.message },
{ status: r.status, headers: r.retryAfter ? { "Retry-After": r.retryAfter } : {} },
);
}
(await cookies()).set("otp_id", r.data.otp_id, {
httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 600,
});
return Response.json({ sent: true });
}
Passing ip (the user’s address, not your server’s) turns on the per-IP limit: 20 codes an hour from one address by default. The answer has the otp_id, the email and expires_at, 10 minutes ahead. app_name is the name shown in the email, up to 40 characters, and cannot be a web address.
Check the code
// app/api/otp/verify/route.ts
import { cookies } from "next/headers";
import { signops } from "@/lib/signops";
export async function POST(request: Request) {
const { code } = await request.json();
const jar = await cookies();
const otpId = jar.get("otp_id")?.value;
if (!otpId) return Response.json({ error: "Ask for a new code." }, { status: 400 });
const r = await signops("/otp/verify", { otp_id: otpId, code });
if (r.status !== 200) {
jar.delete("otp_id");
return Response.json({ error: r.data.error.message }, { status: r.status });
}
if (!r.data.valid) {
return Response.json({ error: "Wrong code", tries_left: r.data.tries_left }, { status: 400 });
}
jar.delete("otp_id");
await createSession(r.data.email); // your own session: a signed cookie, a database row, your auth library
return Response.json({ signedIn: true });
}
Use the email that verify answers, not one from the request body: it is the address the code was sent to.
What can come back
| Answer | Meaning | What to show |
|---|---|---|
200, valid: true |
The code is right; it cannot be used again | Sign the user in |
200, valid: false |
Wrong code, tries_left says how many remain |
“Wrong code” and the count |
429 rate_limited |
Sent too soon or too often; Retry-After has the seconds |
A countdown on the resend button |
410 expired |
Past 10 minutes, or the email bounced | “Send a new code” |
422 too_many_tries |
Five wrong codes | “Send a new code” |
409 already_done |
This code was already verified | Treat as a used code |
403 domain_required |
Live key, domain not verified yet | Fix in the dashboard; test keys work meanwhile |
The form
// app/sign-in/page.tsx
"use client";
import { useState } from "react";
export default function SignIn() {
const [step, setStep] = useState<"email" | "code">("email");
const [error, setError] = useState("");
async function post(url: string, body: object) {
const res = await fetch(url, { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
setError(res.ok ? "" : data.error);
return res.ok;
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
if (step === "email") {
if (await post("/api/otp/send", { email: f.get("email") })) setStep("code");
} else if (await post("/api/otp/verify", { code: f.get("code") })) {
window.location.href = "/";
}
}
return (
<form onSubmit={onSubmit}>
{step === "email"
? <input name="email" type="email" autoComplete="email" required />
: <input name="code" inputMode="numeric" autoComplete="one-time-code" pattern="\d{6}" maxLength={6} required />}
<button>{step === "email" ? "Email me a code" : "Sign in"}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
autoComplete="one-time-code" lets phones offer the code from the email, and inputMode="numeric" opens the number pad.
Before you go live
- Live codes go from your own domain. Add it under Email OTP, Domain in the dashboard (five DNS records), then switch
SIGNOPS_KEYto the live key. - Put a check on the email form, such as Cloudflare Turnstile, so the send route cannot be used to email strangers.
- Do not log the code request body. The code and the
otp_idare enough to sign in for 10 minutes. - Server Actions work the same way. Call
signops("/otp/send", ...)from an action instead of a Route Handler, and set the cookie there.
With SignOps
SignOps Email OTP makes the code, keeps only a hash of it, sends it from your domain and checks it: 10 minutes, 5 tries, a 30-second resend wait and hourly limits per address and per IP, all built in. 500 codes are free to try, then $1 per 1,000 codes. Starter includes 2,000 a month. For the setup in the dashboard, see the email OTP guide; for the same flow in other stacks, see Better Auth and Firebase Auth.
Sources: Next.js: route.js, Next.js: cookies, MDN: autocomplete one-time-code, SignOps API docs.