Skip to content

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.

OTPBy Rushi Patel, Senior iOS developerUpdated 6 November 20267 min read

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_KEY to 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_id are 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.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Do I need Auth.js or NextAuth to add email OTP in Next.js?

No. Two Route Handlers are enough, one to send the code and one to check it, plus your own session once the code is valid. If you already use an auth library, call the same two endpoints from its custom sign-in step.

Where should the otp_id be kept between the two steps?

In an httpOnly cookie set by the send handler. The browser never reads it, and the verify handler takes it from the cookie, so the client only sends the 6 digits.

Can I call the OTP API from a client component?

No. The API key is a secret and must stay on the server. Call it from a Route Handler or a Server Action, and keep the key in an environment variable without the NEXT_PUBLIC_ prefix.

How long does an email code last?

With SignOps, 10 minutes and 5 tries. A resend to the same address waits 30 seconds, and one address gets at most 5 codes an hour by default.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.