Developer Tutorials

Send OTP in Next.js (App Router) — Server Actions Guide 2026

Send and verify SMS OTPs from a Next.js 14/15 App Router app using server actions and the StartMessaging API. Includes a full login form, server actions, and middleware.

18 April 202610 min read

StartMessaging Team

Engineering

The Next.js App Router’s server actions make phone OTP login almost trivial: no API routes, no client-side fetch, just a form that calls a server function. This guide builds a complete OTP login flow backed by the StartMessaging OTP API.

Why Server Actions for OTP

Server actions run on the Next.js server, so your StartMessaging API key never reaches the browser. They progressively enhance form submissions, work without JavaScript, and let you set cookies atomically with the response.

Environment Setup

# .env.local
STARTMESSAGING_API_KEY=sm_live_xxxxxxxxxxxxxxxxxxxx
STARTMESSAGING_BASE_URL=https://api.startmessaging.com

Server Action to Send OTP

// app/(auth)/actions.ts
'use server';

import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { randomUUID } from 'crypto';

const BASE = process.env.STARTMESSAGING_BASE_URL!;
const KEY = process.env.STARTMESSAGING_API_KEY!;

export async function sendOtpAction(formData: FormData) {
  const phone = String(formData.get('phone') ?? '').trim();
  if (!/^\+91\d{10}$/.test(phone)) {
    return { error: 'Enter a valid Indian mobile number' };
  }

  const res = await fetch(`${BASE}/otp/send`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': KEY },
    body: JSON.stringify({ phoneNumber: phone, idempotencyKey: randomUUID() }),
    cache: 'no-store',
  });
  const json = await res.json();
  if (!res.ok) return { error: json.message ?? 'Failed to send OTP' };

  cookies().set('otp_request_id', json.data.requestId, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  });
  redirect('/login/verify');
}

Server Action to Verify OTP

export async function verifyOtpAction(formData: FormData) {
  const code = String(formData.get('code') ?? '').trim();
  const requestId = cookies().get('otp_request_id')?.value;
  if (!requestId) return { error: 'Session expired. Send a new OTP.' };

  const res = await fetch(`${BASE}/otp/verify`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': KEY },
    body: JSON.stringify({ requestId, otpCode: code }),
    cache: 'no-store',
  });
  const json = await res.json();
  if (!res.ok || !json.data?.verified) return { error: 'Wrong code' };

  cookies().delete('otp_request_id');
  cookies().set('session', 'verified', { httpOnly: true, secure: true, sameSite: 'lax' });
  redirect('/dashboard');
}

Client Login Form

// app/(auth)/login/page.tsx
import { sendOtpAction } from '../actions';

export default function LoginPage() {
  return (
    <form action={sendOtpAction} className="space-y-4">
      <input name="phone" type="tel" placeholder="+91 98765 43210" required />
      <button type="submit">Send OTP</button>
    </form>
  );
}

Middleware to Protect Routes

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(req: NextRequest) {
  if (req.nextUrl.pathname.startsWith('/dashboard')) {
    if (req.cookies.get('session')?.value !== 'verified') {
      return NextResponse.redirect(new URL('/login', req.url));
    }
  }
  return NextResponse.next();
}

Best Practices

  1. Use the Web OTP API on the verify page so Chrome on Android can autofill the code. See our guide on OTP autofill on Android & iOS.
  2. Rate-limit the send action by phone and IP using Vercel KV.
  3. Encrypt the session cookie with iron-session or jose.
  4. Show resend timer in the UI to prevent users from spamming send.

FAQ

Curious about pricing? It’s Rs 0.25 per OTP with no DLT registration required.

Ready to Send OTPs?

Integrate StartMessaging in 5 minutes. No DLT registration required.