DEV Community

RivenPulse5812
RivenPulse5812

Posted on

Next.js Phone Verification for Sellers: SMS OTP Backend Timer

Short answer: keep the SMS OTP, resend countdown, and session decision on the backend; let the Next.js button display server state. For a healthtech marketplace seller signing in before handling a new order, this is the smallest design that survives refreshes, multiple tabs, and impatient clicks.

The browser is a view. It is not a rate limiter.

That distinction matters more than the animation. A seller can open two tabs, lose connectivity, or replay a request outside the page. If the countdown lives only in React state, each copy of the page believes it is in charge. The backend needs one verification transaction with one resend policy.

How should Next.js phone verification handle SMS OTP retry timing?

Create a pending transaction when the seller submits a phone number. Store an opaque transaction ID, a normalized destination or a protected reference to it, the expiry time, the resend count, and the next permitted resend time. Return only what the interface needs: the transaction ID, a masked destination, and the server's next-allowed timestamp.

The button calculates its label from that timestamp. It can show Try again in 27s, disable itself, and update once per second. On every click, though, the server checks the timestamp again. A page reload should not reset the policy, and a second tab should see the same answer.

An OTP request is not a login. A phone number is not a login. Only a successful code check should create the authenticated session, and the check should be bound to the pending transaction rather than to a phone number supplied again by the browser.

For the new-order workflow, keep notification delivery separate from authentication. The seller may sign in with an OTP, then receive an order notification through the channel policy for that marketplace. Combining those states makes retries hard to reason about: a notification retry must never create a new login transaction.

The smallest TypeScript implementation

The route below is deliberately provider-neutral. SmsGateway is an adapter owned by the application, while OtpStore represents a datastore operation that atomically claims a resend slot. There is no provider URL to guess and no SDK configuration to hide in the example.

type OtpTransaction = {
  id: string;
  expiresAtMs: number;
  resendCount: number;
  nextResendAtMs: number;
};

type SmsGateway = {
  sendOtp(input: { transactionId: string; phone: string }): Promise<void>;
};

type OtpStore = {
  getPending(id: string): Promise<{ transaction: OtpTransaction; phone: string } | null>;
  claimResend(input: {
    id: string;
    nowMs: number;
    cooldownMs: number;
    maxResends: number;
  }): Promise<"claimed" | "expired" | "cooldown" | "limit">;
};

export async function resendOtp(
  id: string,
  store: OtpStore,
  sms: SmsGateway,
): Promise<{ nextResendAtMs: number }> {
  const record = await store.getPending(id);
  if (!record) throw new Error("OTP transaction not found");

  const nowMs = Date.now();
  if (record.transaction.expiresAtMs <= nowMs) {
    throw new Error("OTP transaction expired");
  }

  const result = await store.claimResend({
    id,
    nowMs,
    cooldownMs: 30_000,
    maxResends: 3,
  });

  if (result === "cooldown") throw new Error("Retry after the server timestamp");
  if (result === "expired") throw new Error("OTP transaction expired");
  if (result === "limit") throw new Error("Resend limit reached");

  await sms.sendOtp({ transactionId: id, phone: record.phone });
  return { nextResendAtMs: nowMs + 30_000 };
}
Enter fullscreen mode Exit fullscreen mode

The important line is not 30_000. It is claimResend. That operation must be atomic: one eligible request advances the timestamp and counter, while a concurrent request receives a policy result instead of sending a second code. If the SMS adapter call fails after the claim, the application needs an explicit state policy for that transaction. Do not silently reset the counter from the browser.

The example uses 3 as a visible policy value, not as a universal security recommendation. Tune expiry, cooldown, and maximum attempts against abuse data, support volume, and the requirements of the product. I would log a correlation ID, country, outcome, latency, and provider response class, while keeping phone numbers and OTP values out of logs. A 429 should be treated as a throttling response, not as permission to create another transaction. If an operator later asks why a seller received two messages, the audit record should show which request claimed the slot, which request was rejected, and whether the gateway accepted the one permitted send. That evidence is more useful than a front-end screenshot because it describes the authoritative state at the moment of the decision.

No magic.

What changes for US and EU phone verification?

The code path can stay the same while policy changes by deployment or account. Decide which countries the marketplace serves, how phone numbers are normalized, which sender identity is allowed, and how long verification records may remain. Keep those decisions in a server-side policy module, with tests for an allowed US number, an allowed EU number, an unsupported country, and an expired transaction.

Country selection is not the same as delivery certainty. A successful request to an SMS gateway means the send operation was accepted by that adapter; it does not prove that a seller read the message. Track delivery outcomes where the chosen channel provides them, and give support staff the transaction ID and timestamps they need to investigate a missing code without exposing the code itself.

A fallback email path introduces different concerns. DKIM is specified in RFC 6376, so domain authentication belongs in the email design rather than being treated as an afterthought. Open events are also a weak success signal: Apple's Mail Privacy Protection guide documents privacy behavior that affects how email activity is observed. For login, the verification code remains the authority; an open pixel should never mark a seller as authenticated.

I'm not sure which retention period or country list fits a particular healthtech marketplace. Traffic mix, abuse patterns, legal review, and the actual notification contract resolve that uncertainty. The implementation should make those inputs configurable without making them browser-controlled.

What are the trade-offs in a backend OTP design?

The backend-owned model adds a datastore write, an atomic claim, and observability work. That is the cost. In return, the countdown has one authority across tabs and devices, and the session transition has a clear audit trail.

Design choice Good fit Main trade-off
Browser-only timer A low-risk demo with no real account access Refreshes and parallel tabs can bypass the visual state
Backend transaction A seller login that protects account and order data Requires storage, atomic updates, and operational logs
Managed verification flow A team that values built-in policy controls Less control over the transaction boundary and channel behavior

It is not suitable when the product cannot operate a server-side transaction store or when the requirement is a channel outside SMS and email. In that case, choose an authentication system whose supported channels and operational controls match the requirement. Stick with a managed verification flow when your team needs its built-in policy controls more than it needs a custom transaction boundary.

There is also a UX trade-off. A long cooldown frustrates a seller who genuinely missed a code; a short one gives an attacker more chances and can produce message confusion. Start with a modest policy, measure legitimate resend behavior, and change one variable at a time. The button should explain the wait, but the server should make the decision.

That is the rule I would carry into the new-order notification system: transport can be swapped, but authorization state and risk policy stay in the application. Fast integration is useful only when it leaves those decisions visible.

References

Top comments (0)