Next.js phone login: a resend countdown the server actually owns

App Router route handlers for SMS OTP where the cooldown survives a page refresh, the timer comes from the API response, and a bad number never costs you a send.

Put the timer on the server. A React useState countdown resets on every refresh, so the client can only ever display a cooldown that a route handler decides. In a Next.js App Router app calling Infrai, that means a challenge record with a last_sent_at, a POST /v1/sms/otp behind it, and a JSON field named retry_after_seconds that the button reads — because this API emits no Retry-After header for the browser to use.

Everything else in a phone-login flow follows from one detail of the managed OTP response, so start there.

What the OTP call hands back

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/otp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+441632960123", "template": "Your sign-in code is {code}"}'
{
  "ok": true,
  "data": { "request_id": "smsotp_9TkQm2xBvR7", "sent": true },
  "metadata": { "vendor": "tencent_sms", "vendor_region": "western", "cost_usd": 0.007475 }
}

request_id and sent. That’s the whole payload — no message_id, which means there is no handle to poll delivery with, because GET /v1/sms/status/{id} is keyed on the id that POST /v1/sms/send returns instead. The gateway is holding the code, its expiry and its attempt counter for you, and the price of that is that your UI has no “delivered” state to render.

Which is fine, and it simplifies the front end considerably: the only honest thing to show after a send is a countdown.

The countdown, and why it isn’t useState

Three failure modes kill a client-only timer. The user refreshes and gets a fresh 30 seconds that the server never agreed to. The user opens a second tab and gets two independent timers. The user opens devtools and sets the state to zero.

So the server stores when it last sent, and every response — the initial send, the resend, even a plain GET on page load — carries the remaining seconds.

// lib/challenge.ts — one record per phone number, server-side.
export type Challenge = { phone: string; lastSentAt: number; sends: number };

const COOLDOWN_SECONDS = 45;
const MAX_SENDS_PER_CHALLENGE = 4;
const store = new Map<string, Challenge>();

export function remainingSeconds(c: Challenge | undefined): number {
  if (!c) return 0;
  const elapsed = (Date.now() - c.lastSentAt) / 1000;
  return Math.max(0, Math.ceil(COOLDOWN_SECONDS - elapsed));
}

export function canSend(phone: string): { ok: boolean; retryAfter: number; reason?: string } {
  const c = store.get(phone);
  const retryAfter = remainingSeconds(c);
  if (c && c.sends >= MAX_SENDS_PER_CHALLENGE) {
    return { ok: false, retryAfter, reason: "send_cap_reached" };
  }
  if (retryAfter > 0) return { ok: false, retryAfter, reason: "cooling_down" };
  return { ok: true, retryAfter: 0 };
}

export function recordSend(phone: string): Challenge {
  const c = store.get(phone) ?? { phone, lastSentAt: 0, sends: 0 };
  c.lastSentAt = Date.now();
  c.sends += 1;
  store.set(phone, c);
  return c;
}

export function readChallenge(phone: string): Challenge | undefined {
  return store.get(phone);
}

A Map is right for one dev server and wrong for three serverless instances — move it to Redis or a phone_challenges row before you deploy, or your cooldown is per-lambda and therefore not a cooldown.

Normalise the number before you spend anything

US and EU numbers arrive in a dozen shapes: (415) 555-0132, 07700 900123, +33 6 12 34 56 78. The API wants E.164, and a number that isn’t E.164 doesn’t come back as a 400.

It comes back as HTTP 503 VENDOR_DOWN with retryable: true, and the actual reason is only in the message string. Worth flagging loudly: a generic “retry on 5xx” wrapper will retry a permanently malformed number forever, and each attempt that does reach the carrier is a paid message. Validate first.

// lib/phone.ts — reject before the network call, not after.
const E164 = /^\+[1-9]\d{7,14}$/;

export function toE164(raw: string, defaultCountry: "US" | "GB" | "FR"): string | null {
  const digits = raw.replace(/[^\d+]/g, "");
  if (digits.startsWith("+")) return E164.test(digits) ? digits : null;
  const cc = { US: "+1", GB: "+44", FR: "+33" }[defaultCountry];
  const national = digits.replace(/^0+/, "");
  const candidate = `${cc}${national}`;
  return E164.test(candidate) ? candidate : null;
}

For production, a real library beats fourteen lines of regex — libphonenumber-js knows which UK mobile prefixes exist and this doesn’t. The point is only that the check happens before the send.

The route handlers

Two files under app/api/auth/phone/. Both run on the server, so the API key never reaches the browser.

// app/api/auth/phone/start/route.ts
import { NextResponse } from "next/server";
import { canSend, recordSend, readChallenge, remainingSeconds } from "@/lib/challenge";
import { toE164 } from "@/lib/phone";

const INFRAI = "https://api.infrai.cc";

export async function POST(req: Request): Promise<NextResponse> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) return NextResponse.json({ error: "server_misconfigured" }, { status: 500 });

  const { phone, country } = await req.json();
  const to = toE164(String(phone ?? ""), country ?? "US");
  if (!to) return NextResponse.json({ error: "invalid_phone" }, { status: 400 });

  const gate = canSend(to);
  if (!gate.ok) {
    return NextResponse.json(
      { error: gate.reason, retry_after_seconds: gate.retryAfter },
      { status: 429 },
    );
  }

  const res = await fetch(`${INFRAI}/v1/sms/otp`, {
    method: "POST",
    headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
    body: JSON.stringify({ to, template: "Your sign-in code is {code}" }),
  });
  const payload = await res.json();

  if (payload.ok === false) {
    const code = payload.error?.code ?? "unknown";
    console.error("otp send failed", code, payload.error?.message);
    return NextResponse.json({ error: code, retry_after_seconds: 0 }, { status: 502 });
  }

  const c = recordSend(to);
  return NextResponse.json({
    sent: payload.data.sent === true,
    sends_used: c.sends,
    retry_after_seconds: remainingSeconds(readChallenge(to)),
  });
}

The resend button posts to this same handler. There’s a POST /v1/sms/resend/{id} route on the SMS surface, but it re-sends a message you created with POST /v1/sms/send, keyed on that message id — a managed OTP has no such id, so resend means calling /v1/sms/otp again and paying for another message. That’s the single most common wrong assumption in this flow.

// app/api/auth/phone/verify/route.ts
import { NextResponse } from "next/server";
import { toE164 } from "@/lib/phone";

const INFRAI = "https://api.infrai.cc";
const attempts = new Map<string, number>();

export async function POST(req: Request): Promise<NextResponse> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) return NextResponse.json({ error: "server_misconfigured" }, { status: 500 });

  const { phone, code, country } = await req.json();
  const to = toE164(String(phone ?? ""), country ?? "US");
  if (!to || !/^\d{4,8}$/.test(String(code ?? ""))) {
    return NextResponse.json({ error: "invalid_input" }, { status: 400 });
  }

  const used = (attempts.get(to) ?? 0) + 1;
  attempts.set(to, used);
  if (used > 6) return NextResponse.json({ error: "too_many_attempts" }, { status: 429 });

  const res = await fetch(`${INFRAI}/v1/sms/verify`, {
    method: "POST",
    headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
    body: JSON.stringify({ to, code: String(code) }),
  });
  const payload = await res.json();
  const verified = payload.ok !== false && payload.data?.verified === true;
  if (verified) attempts.delete(to);
  return NextResponse.json({ verified }, { status: verified ? 200 : 401 });
}

That used > 6 guard is not decoration. A verify call is billed whether or not the code matches — a wrong code answers verified: false and still charges the account — so an unthrottled verify handler lets a stranger spend your credit at whatever rate their script manages.

The button

"use client";
import { useEffect, useState } from "react";

export function ResendButton({ phone, country }: { phone: string; country: "US" | "GB" | "FR" }) {
  const [left, setLeft] = useState(0);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    if (left <= 0) return;
    const t = setInterval(() => setLeft((n) => Math.max(0, n - 1)), 1000);
    return () => clearInterval(t);
  }, [left]);

  async function resend() {
    setBusy(true);
    try {
      const res = await fetch("/api/auth/phone/start", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ phone, country }),
      });
      const data = await res.json();
      setLeft(Number(data.retry_after_seconds ?? 45));
    } finally {
      setBusy(false);
    }
  }

  return (
    <button onClick={resend} disabled={busy || left > 0}>
      {left > 0 ? `Resend in ${left}s` : "Resend code"}
    </button>
  );
}

Notice the client takes retry_after_seconds from the response in both the success and the 429 case. The server is the clock; the button is a label.

What one login costs

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 300

curl -sS "https://api.infrai.cc/v1/account/balance" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Verified 2026-07-26 on Infrai: the OTP message is billed per message at $0.007475 and the verify call per call at $0.005, so a clean login lands near $0.0125 and one with a single resend near $0.02. New accounts start with $2 of free credit, roughly 267 messages. Read those numbers from GET /v1/discovery rather than from this page in six months — the direction of travel on this surface has been downward, and discount campaigns run.

Where a specialist is the better call

Twilio Verify does things this flow doesn’t: it ships WhatsApp and voice fallback channels, per-country delivery routing you can tune, and a fraud-scoring layer aimed at SMS pumping. Vonage’s Verify API is in the same category. If phone verification is the security perimeter of your product and you have the volume to care about per-country conversion, that’s where to stick with a specialist.

Infrai sms.otpTwilio VerifyVonage Verify
Code generation, TTL, attempt limitmanaged for youmanagedmanaged
ChannelsSMSSMS, voice, WhatsApp, email, TOTPSMS, voice, WhatsApp
Delivery receipts for the OTPnone — no message_id is returnedyesyes
Anti-pumping / fraud scoringnot offeredyesyes
Same credential also does email, queues, cron, storageyesnono

The trade-off runs the other way for most Next.js apps. Infrai’s SMS surface is region-tagged western, it doesn’t support delivery webhooks or provider-managed subscription groups, and sending into China needs an approved signature and template first — but the same key that sent this code also sends the welcome email, runs the cron that expires stale challenges and captures the exception when the route handler throws. That’s one credential and one invoice instead of four, and for a login form the missing DLR stream costs you nothing, since the real health signal is your verify success rate.

References

Browse more sms developer guides