OTP verification in Next.js with a server action and a resend timer

The whole code flow for the App Router: make a 6 digit code, store only its hash with an expiry and a try limit, check it in a server action, submit on the sixth digit, and let people ask for a new code after a 30 second countdown.

By Teodor Avadani · Updated October 25, 2026

How the flow works

1. The verify page opens and asks the server for a code. 2. The server makes a random 6 digit code, keeps an HMAC of it with a 10 minute expiry, and sends the code by email or SMS. 3. The person types or pastes it, and the form submits on the sixth digit. 4. A server action compares hashes, counts tries, and redirects when it matches. 5. A Send a new code button unlocks after 30 seconds.

The server never stores the plain code, a code works once, and after 5 wrong tries the person needs a new one. Tested on Next.js 15.5 with React 19 and Tailwind v4.

1. Make, store and check codes

randomInt gives a uniform code, createHmac keeps only a hash, and timingSafeEqual compares without leaking timing. Set OTP_SECRET in your env. The resend wait lives here too, so the server enforces it even if someone skips the button.

lib/otp.ts
import "server-only";
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";

const SECRET = process.env.OTP_SECRET ?? "dev-only-secret-change-me";
const TTL_MS = 10 * 60 * 1000; // a code lives 10 minutes
const MAX_TRIES = 5;           // then the user needs a new code
export const RESEND_AFTER_S = 30;

type Entry = { hash: string; expires: number; tries: number; sentAt: number };
// In memory so the demo runs. Use Redis or a database table in production:
// serverless functions do not share memory between requests.
const store = new Map<string, Entry>();

const hash = (email: string, code: string) =>
  createHmac("sha256", SECRET).update(`${email}:${code}`).digest("hex");

export function issueCode(email: string): { code: string } | { waitS: number } {
  const old = store.get(email);
  const since = old ? (Date.now() - old.sentAt) / 1000 : Infinity;
  if (since < RESEND_AFTER_S) return { waitS: Math.ceil(RESEND_AFTER_S - since) };

  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  store.set(email, { hash: hash(email, code), expires: Date.now() + TTL_MS, tries: 0, sentAt: Date.now() });
  return { code };
}

export function checkCode(email: string, code: string): "ok" | "wrong" | "expired" | "locked" {
  const e = store.get(email);
  if (!e || Date.now() > e.expires) return "expired";
  if (e.tries >= MAX_TRIES) return "locked";
  e.tries += 1;
  const a = Buffer.from(hash(email, code));
  const b = Buffer.from(e.hash);
  if (a.length === b.length && timingSafeEqual(a, b)) {
    store.delete(email); // a code works once
    return "ok";
  }
  return "wrong";
}

The Map keeps the demo self contained. On Vercel or any serverless host, two requests can land on different instances, so move the entries to Redis or a table with the same fields.

2. Server actions to send and verify

sendCode returns how many seconds the button should wait, which is either a fresh 30 or what is left of the last one. verifyCode turns each result into a message the person can act on.

app/verify/actions.ts
"use server";

import { redirect } from "next/navigation";
import { checkCode, issueCode, RESEND_AFTER_S } from "@/lib/otp";

export type VerifyState = { error?: string };

export async function sendCode(email: string): Promise<{ waitS: number }> {
  const r = issueCode(email);
  if ("waitS" in r) return { waitS: r.waitS };
  // Send r.code with your email or SMS provider here.
  console.log(`[otp] code for ${email}: ${r.code}`);
  return { waitS: RESEND_AFTER_S };
}

export async function verifyCode(_prev: VerifyState, formData: FormData): Promise<VerifyState> {
  const email = String(formData.get("email") ?? "");
  const code = String(formData.get("code") ?? "").replace(/\D/g, "");
  if (code.length !== 6) return { error: "Enter the 6 digit code." };

  const result = checkCode(email, code);
  if (result === "wrong") return { error: "That code is not right. Check the latest email." };
  if (result === "expired") return { error: "That code has expired. Send a new one." };
  if (result === "locked") return { error: "Too many tries. Send a new code." };

  // Create the session here (your auth library), then:
  redirect("/dashboard");
}

3. The code field, auto submit and the countdown

One input with autoComplete="one-time-code" lets phones offer the code from Messages or Mail, and inputMode="numeric" opens the number pad. When the sixth digit lands, requestSubmit() sends the form, so nobody has to find a button.

app/verify/OtpForm.tsx
"use client";

import { useActionState, useEffect, useRef, useState } from "react";
import { sendCode, verifyCode, type VerifyState } from "./actions";

export function OtpForm({ email }: { email: string }) {
  const [state, action, pending] = useActionState<VerifyState, FormData>(verifyCode, {});
  const [wait, setWait] = useState(0);
  const formRef = useRef<HTMLFormElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  // Send the first code when the page opens.
  useEffect(() => {
    sendCode(email).then((r) => setWait(r.waitS));
  }, [email]);

  // The resend countdown: one tick per second until it reaches zero.
  useEffect(() => {
    if (wait <= 0) return;
    const t = setTimeout(() => setWait((w) => w - 1), 1000);
    return () => clearTimeout(t);
  }, [wait]);

  // After a wrong code, clear the field and put the cursor back.
  useEffect(() => {
    if (state.error && inputRef.current) {
      inputRef.current.value = "";
      inputRef.current.focus();
    }
  }, [state]);

  return (
    <form ref={formRef} action={action} className="space-y-4">
      <input type="hidden" name="email" value={email} />
      <label htmlFor="code" className="text-sm font-medium">Code from your email</label>
      <input
        ref={inputRef}
        id="code"
        name="code"
        inputMode="numeric"
        autoComplete="one-time-code"
        pattern="[0-9]{6}"
        autoFocus
        aria-invalid={!!state.error}
        aria-describedby="code-status"
        onChange={(e) => {
          const digits = e.currentTarget.value.replace(/\D/g, "").slice(0, 6);
          e.currentTarget.value = digits;
          if (digits.length === 6 && !pending) formRef.current?.requestSubmit();
        }}
        className={`w-full rounded-lg border bg-zinc-900 px-3 py-3 text-center font-mono text-2xl tracking-[0.5em] outline-none transition focus:ring-4 ${
          state.error
            ? "border-red-400 focus:ring-red-400/20 motion-safe:animate-[shake_.35s]"
            : "border-zinc-700 focus:border-sky-300 focus:ring-sky-300/20"
        }`}
      />
      <p id="code-status" aria-live="polite" className="min-h-5 text-sm text-red-400">
        {pending ? <span className="text-zinc-400">Checking...</span> : state.error}
      </p>
      <button
        type="button"
        disabled={wait > 0}
        onClick={() => sendCode(email).then((r) => setWait(r.waitS))}
        className="text-sm text-sky-300 underline-offset-4 hover:underline disabled:text-zinc-500 disabled:no-underline"
      >
        {wait > 0 ? `Send a new code in ${wait}s` : "Send a new code"}
      </button>
    </form>
  );
}

The countdown is a timeout that runs once per second while wait is above zero, so there is no interval to clean up. After a wrong code the field clears and gets focus back, and the status line is aria-live so screen readers hear the result.

4. The page

The page reads the email from the URL and renders the form. Add the shake keyframe to your global CSS; motion-safe: skips it for reduced motion.

app/verify/page.tsx
import { OtpForm } from "./OtpForm";

export default async function VerifyPage({ searchParams }: { searchParams: Promise<{ email?: string }> }) {
  const { email = "" } = await searchParams;
  return (
    <main className="grid min-h-dvh place-items-center bg-zinc-950 px-5 text-zinc-100">
      <div className="w-full max-w-sm rounded-2xl border border-zinc-800 bg-zinc-900/60 p-6">
        <h1 className="text-2xl font-semibold">Check your email</h1>
        <p className="mt-1 text-sm text-zinc-400">We sent a 6 digit code to {email || "your email"}.</p>
        <div className="mt-6">
          <OtpForm email={email} />
        </div>
      </div>
    </main>
  );
}
app/globals.css
@keyframes shake {
  25% { transform: translateX(-6px); }
  50% { transform: translateX(5px); }
  75% { transform: translateX(-3px); }
}

Before you ship

Send the code with your email or SMS provider where the console.log is. Create the session in verifyCode before the redirect. Rate limit sendCode per IP as well as per email, so nobody can use your form to spam an inbox. If you use Supabase, Clerk or Better Auth, their email OTP calls do the storing and checking for you: keep the form and the countdown and call their verify method in the action.

Using Cursor, v0 or Lovable

Cursor: add lib/otp.ts first and ask it to wire your mail provider into sendCode. Tell it to keep the code check on the server.

v0: it tends to draw six separate boxes. Ask for a single input with autocomplete one-time-code, or the paste and autofill cases break on phones.

Lovable: there are no server actions in its Vite projects. Use Supabase email OTP (signInWithOtp, then verifyOtp) and keep the countdown and auto submit from OtpForm.

Or use the ready-made piece

Step-Up OTP Gate is the animated version of this screen: six code cells with paste and one-time-code autofill, an error shake, a resend link with its timer, and arcs that spin while your check runs. You pass the server check from this guide as onVerify, and true unlocks the page it wraps.

Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.

Questions

Where should I store OTP codes in Next.js?
Store a hash of the code, never the code itself, with an expiry and a try count, in Redis or a database table. Memory works for local testing only.
How long should an OTP code last?
Five to ten minutes is common. This guide uses 10 minutes and ends a code after 5 wrong tries or one correct use.
How do I make a resend OTP countdown timer in React?
Keep the seconds left in state and run a one second setTimeout while it is above zero. Disable the button until it reaches zero, and enforce the same wait on the server.
Should the OTP input be one field or six?
One field is easier to get right: paste and phone autofill fill it in one go. You can style it to look like six cells.

Read next

OTP Verification in Next.js: Server Action + Resend Timer