code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Sinch

How to Implement SMS OTP Verification in Node.js and Next.js with Sinch (2025 Guide)

Learn how to build secure phone number verification and SMS two-factor authentication (2FA) using Sinch Verification API, Node.js, Next.js, and Redis. Complete implementation guide with working code examples.

Sinch SMS OTP & 2FA: Complete Node.js + Next.js Implementation Guide

This comprehensive guide walks you through implementing secure phone number verification and SMS-based two-factor authentication (2FA) using Sinch's Verification API. You'll learn how to build a production-ready system with a Next.js frontend and Node.js (Express) backend that handles OTP generation, SMS delivery via Sinch, and secure verification.

SMS OTP authentication is essential for verifying user phone numbers during sign-up, securing login flows with an additional authentication factor, and confirming high-value transactions. By the end of this tutorial, you'll have a fully functional passwordless authentication system that enhances security while maintaining excellent user experience.

Project Overview and Goals

What We'll Build:

  • A Node.js (Express) backend API responsible for:
    • Generating secure OTPs using Node.js's crypto module.
    • Storing OTPs temporarily (using Redis).
    • Sending OTPs to users' phone numbers via the Sinch Verification API.
    • Verifying user-submitted OTPs against the stored values.
  • A Next.js frontend application with:
    • A form to capture the user's phone number and request an OTP.
    • A form to submit the received OTP for verification.
    • Communication logic to interact with the backend API.

Problem Solved: Securely verify user phone numbers and implement multi-factor authentication (MFA) using SMS one-time passwords. This implementation leverages Sinch's reliable global SMS delivery infrastructure and follows modern security best practices for OTP verification systems.

Technologies Used:

  • Node.js: JavaScript runtime environment for the backend server.
  • Express.js: Minimalist web framework for building the REST API.
  • Next.js: React-based framework for the frontend application (v15.x as of 2025; App Router requires React 19 for full features).
  • Sinch: Communications Platform as a Service (CPaaS) for reliable global SMS delivery via their Verification API.
  • Redis: High-performance in-memory data store for secure temporary OTP storage with automatic expiration. We'll use ioredis (note: ioredis is maintained on best-effort basis; for new projects in 2025, consider node-redis as the officially recommended client).
  • dotenv: Environment variable management for secure credential storage.
  • @sinch/sdk-core & @sinch/verification: Official Sinch Node.js SDKs for the Verification API (reached GA v1.0.0 in May 2024). This guide uses @sinch/verification, which targets the modern Sinch Verification API product.
  • axios (optional): Promise-based HTTP client for frontend-to-backend communication.
  • express-rate-limit: Middleware for preventing brute-force attacks on OTP endpoints.

Sources: Next.js 15 release notes (nextjs.org); Sinch SDK GA announcement (May 2024); ioredis deprecation notice (redis.io/docs).

System Architecture:

mermaid
graph LR
    A[Next.js Frontend <br/> (User Interface)] -- HTTP Request <br/> (Request/Verify OTP) --> B(Node.js Backend <br/> (Express API));
    B -- Sinch API Call <br/> (Send SMS OTP) --> C(Sinch API);
    C -- SMS --> UserDevice(User's Phone);
    B -- Store/Retrieve OTP --> D(Redis <br/> (OTP Store));
    B -- HTTP Response <br/> (Success/Error) --> A;

style A fill:#f9f,stroke:#333,stroke-width:2px
style B fill:#ccf,stroke:#333,stroke-width:2px
style C fill:#cff,stroke:#333,stroke-width:2px
style D fill:#ffc,stroke:#333,stroke-width:2px
style UserDevice fill:#eee,stroke:#333,stroke-width:1px

Prerequisites:

  • Node.js (v20 "Iron" LTS or v22 "Jod" LTS recommended for production; Node.js 18 reaches end-of-life April 30, 2025)
  • npm or yarn package manager
  • Sinch account with API credentials (sign up free)
  • Redis instance (local installation or cloud provider like Upstash, Redis Cloud, or AWS ElastiCache)
  • Basic knowledge of Node.js, Express.js, React, and Next.js fundamentals
  • Code editor (VS Code recommended)
  • API testing tool (Postman, Insomnia, or curl command-line tool)

Source: Node.js release schedule (nodejs.org); Node.js 18 EOL confirmed April 30, 2025.

Final Outcome: A functional application where users can enter their phone number, receive an SMS OTP via Sinch, and verify that OTP to gain access or confirm an action.


1. Setting Up the Backend (Node.js/Express)

We'll start by creating the backend application that will handle the core logic for OTP generation and verification. This backend API will manage secure random code generation, temporary storage in Redis, and integration with Sinch's SMS delivery service.

Steps:

  1. Create Project Directory:

    bash
    mkdir sinch-otp-backend
    cd sinch-otp-backend
  2. Initialize Node.js Project:

    bash
    npm init -y
  3. Install Dependencies:

    bash
    npm install express dotenv ioredis @sinch/sdk-core @sinch/verification cors express-rate-limit
    • express: Web framework.
    • dotenv: Loads environment variables.
    • ioredis: Redis client.
    • @sinch/sdk-core, @sinch/verification: Sinch SDKs for the Verification API.
    • cors: Enables Cross-Origin Resource Sharing (needed for frontend communication).
    • express-rate-limit: Basic rate limiting to prevent abuse.
  4. Create .env File: Create a file named .env in the sinch-otp-backend root directory. This file will store sensitive credentials and configuration. Never commit this file to version control.

    dotenv
    # .env
    
    # Server Configuration
    PORT=4000
    
    # Redis Configuration
    REDIS_URL=redis://localhost:6379 # Replace with your Redis connection string (e.g., redis://:password@hostname:port)
    
    # Sinch API Credentials (Get these from your Sinch Dashboard)
    # Navigate to Settings -> API Credentials
    SINCH_KEY_ID=YOUR_SINCH_KEY_ID
    SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
    SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID # Find this in your Sinch project settings
    
    # Frontend URL (for CORS)
    FRONTEND_URL=http://localhost:3000
    • PORT: The port your backend server will run on.
    • REDIS_URL: The connection string for your Redis instance.
    • SINCH_KEY_ID, SINCH_KEY_SECRET: Found in your Sinch Dashboard under Settings > API Credentials. Treat these like passwords.
    • SINCH_PROJECT_ID: Your Sinch Project ID, usually visible in the dashboard URL or project settings.
    • FRONTEND_URL: The URL of your Next.js frontend, used for CORS configuration.
  5. Create Basic Server File (server.js): Create a file named server.js in the root directory.

    javascript
    // server.js
    require('dotenv').config(); // Load environment variables first
    const express = require('express');
    const Redis = require('ioredis');
    const cors = require('cors');
    const rateLimit = require('express-rate-limit');
    const crypto = require('crypto'); // For secure OTP generation
    const Sinch = require('@sinch/sdk-core'); // Core SDK
    const { SmsVerification } = require('@sinch/verification'); // Verification specific part
    
    // --- Configuration ---
    const PORT = process.env.PORT || 4000;
    const OTP_EXPIRY_SECONDS = 300; // 5 minutes
    // Note: NIST SP 800-63B recommends OTPs change at least once every 2 minutes (120s).
    // This implementation uses 5 minutes for improved user experience, but consider
    // reducing to 120-180 seconds for stricter security compliance.
    // Source: NIST Special Publication 800-63B (Digital Identity Guidelines).
    
    // --- Initialization ---
    const app = express();
    
    // Initialize Redis Client
    const redisClient = new Redis(process.env.REDIS_URL, {
      // Optional: Add error handling for Redis connection
      retryStrategy(times) {
        const delay = Math.min(times * 50, 2000); // Exponential backoff
        console.warn(`Redis connection failed, retrying in ${delay}ms... (Attempt ${times})`);
        return delay;
      },
      maxRetriesPerRequest: 3, // Limit retries per command
    });
    
    redisClient.on('error', (err) => {
      console.error('Redis Client Error:', err);
      // Implement more robust error handling/alerting in production
    });
    redisClient.on('connect', () => {
      console.log('Connected to Redis');
    });
    
    // Initialize Sinch Client
    if (!process.env.SINCH_KEY_ID || !process.env.SINCH_KEY_SECRET || !process.env.SINCH_PROJECT_ID) {
      console.error('FATAL ERROR: Missing Sinch API credentials in .env file.');
      process.exit(1); // Exit if credentials are missing
    }
    
    const sinchCredentials = {
      projectId: process.env.SINCH_PROJECT_ID,
      keyId: process.env.SINCH_KEY_ID,
      keySecret: process.env.SINCH_KEY_SECRET,
    };
    
    const sinchClient = new Sinch.SinchClient(sinchCredentials);
    const verificationService = new SmsVerification(sinchClient); // Initialize the SMS Verification service
    
    
    // --- Middleware ---
    app.use(express.json()); // Parse JSON request bodies
    
    // CORS Configuration
    const corsOptions = {
      origin: process.env.FRONTEND_URL, // Allow only your frontend
      optionsSuccessStatus: 200 // Some legacy browsers choke on 204
    };
    app.use(cors(corsOptions));
    
    // Rate Limiting (apply to specific routes later for more granularity)
    const limiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 100, // Limit each IP to 100 requests per windowMs
      standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
      legacyHeaders: false, // Disable the `X-RateLimit-*` headers
      message: 'Too many requests from this IP, please try again after 15 minutes',
    });
    app.use(limiter); // Apply to all routes for now
    
    // --- API Routes (To be added in next steps) ---
    // Placeholder for health check
    app.get('/health', (req, res) => {
        res.status(200).json({ status: 'OK', redis: redisClient.status });
    });
    
    // --- Start Server ---
    // Assign the server instance for graceful shutdown
    const server = app.listen(PORT, () => {
      console.log(`Backend server running on http://localhost:${PORT}`);
    });
    
    // Graceful Shutdown Handling
    const shutdown = () => {
      console.info('Signal received: closing HTTP server');
      server.close(() => {
        console.log('HTTP server closed');
        redisClient.quit(() => {
          console.log('Redis connection closed');
          process.exit(0);
        });
      });
    };
    
    process.on('SIGTERM', shutdown);
    process.on('SIGINT', shutdown); // Handle Ctrl+C locally
    • dotenv.config(): Must be called early to load .env variables.
    • Redis Client: Initializes ioredis with the URL from .env. Includes basic retry logic and error handling.
    • Sinch Client: Initializes the Sinch core client and the specific SmsVerification service using credentials from .env. Includes a check for missing credentials.
    • Middleware:
      • express.json(): Parses incoming JSON requests.
      • cors(): Enables requests from your frontend URL specified in .env. Crucial for development and production.
      • rateLimit(): Basic protection against brute-force attacks.
    • Graceful Shutdown: The app.listen return value is stored in server, allowing server.close() to be called correctly on SIGTERM or SIGINT.
  6. Add Start Script to package.json: Open package.json and add/ensure you have a start script:

    json
    {
      "name": "sinch-otp-backend",
      "version": "1.0.0",
      "description": "",
      "main": "server.js",
      "scripts": {
        "start": "node server.js",
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "@sinch/sdk-core": "...",
        "@sinch/verification": "...",
        "cors": "...",
        "dotenv": "...",
        "express": "...",
        "express-rate-limit": "...",
        "ioredis": "..."
      }
    }
  7. Initial Run: Make sure your Redis server is running. Then, start the backend:

    bash
    npm start

    You should see Backend server running on http://localhost:4000 and Connected to Redis if everything is configured correctly. Access http://localhost:4000/health in your browser or Postman to check the health endpoint.


2. Implementing Core Functionality (Backend API Endpoints)

Now, let's build the REST API endpoints for requesting and verifying OTPs. These endpoints form the backbone of your phone verification system and handle the complete authentication flow from code generation to validation.

Steps:

  1. Create OTP Generation Logic: Use Node.js's built-in crypto module for secure random number generation.

    Add this function within server.js, before the API routes:

    javascript
    // server.js (add this function)
    
    // --- Helper Functions ---
    function generateOtp(length = 6) {
      // Generate a cryptographically secure random integer
      const min = Math.pow(10, length - 1);
      const max = Math.pow(10, length) - 1;
      try {
        // crypto.randomInt is preferred for security-sensitive generation
        // It generates an integer between min (inclusive) and max + 1 (exclusive)
        return crypto.randomInt(min, max + 1).toString().padStart(length, '0');
      } catch (error) {
        console.error('FATAL: Error generating secure OTP:', error);
        // In a real application, you might want to throw or handle this more gracefully
        // than falling back to Math.random, which is not cryptographically secure.
        // For this example, we'll throw to highlight the issue.
        throw new Error('Failed to generate secure OTP.');
      }
    }
    • Security: Uses crypto.randomInt which is suitable for security-sensitive values like OTPs.
    • Padding: Ensures the OTP is always the desired length (e.g., 012345 instead of 12345).
  2. Create Request OTP Endpoint (/api/otp/request): This endpoint receives a phone number, generates an OTP, stores it in Redis, and triggers Sinch to send the SMS.

    Add this route handler in server.js under the // --- API Routes --- comment:

    javascript
    // server.js (add this route)
    
    // Rate limiting specific to OTP requests (more strict)
    const otpRequestLimiter = rateLimit({
        windowMs: 5 * 60 * 1000, // 5 minutes
        max: 5, // Limit each IP to 5 OTP requests per 5 minutes
        standardHeaders: true,
        legacyHeaders: false,
        message: 'Too many OTP requests, please try again later.',
    });
    
    app.post('/api/otp/request', otpRequestLimiter, async (req, res) => {
        const { phoneNumber } = req.body;
    
        // 1. Validate Input
        if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
            // Basic E.164 format check (adjust regex as needed for stricter validation)
            return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format (e.g., +1234567890).' });
        }
    
        try {
            // 2. Generate OTP
            const otp = generateOtp(6); // Generate a 6-digit OTP
            const redisKey = `otp:${phoneNumber}`;
    
            // 3. Store OTP in Redis with Expiry
            // Use SET with EX option for atomicity (Set and Expire)
            await redisClient.set(redisKey, otp, 'EX', OTP_EXPIRY_SECONDS);
            console.log(`OTP ${otp} generated for ${phoneNumber}, expires in ${OTP_EXPIRY_SECONDS}s`);
    
            // 4. Send OTP via Sinch Verification API
            console.log(`Attempting to send OTP to ${phoneNumber} via Sinch...`);
    
            // --- IMPORTANT: Verify Sinch SDK Payload Structure ---
            // The exact structure of this request object might change between SDK versions.
            // Always consult the official @sinch/verification SDK documentation for the
            // 'startSms' method to ensure the payload format is correct.
            const startVerificationRequest = {
                smsVerificationStartRequest: {
                    identity: { type: 'number', endpoint: phoneNumber },
                    reference: `MyAppVerification_${Date.now()}`, // Optional reference for tracking
                    // You can often customize SMS templates in the Sinch dashboard.
                    // Specifying custom content here might depend on your Sinch plan/settings.
                }
            };
    
            // Ensure verificationService is initialized (though we check at startup)
             if (!verificationService) {
                console.error('Sinch Verification Service not initialized!');
                return res.status(500).json({ error: 'Internal server error (Sinch service unavailable)' });
            }
    
            const response = await verificationService.startSms(startVerificationRequest);
    
            // --- IMPORTANT: Verify Sinch SDK Success Response ---
            // Check the actual structure of the 'response' object returned by the SDK
            // in successful cases. The success condition might involve different fields
            // or checks than just 'response.id'. Log the response for debugging.
            console.log('Sinch Start SMS Verification Response:', response);
    
            // Assuming success if no error is thrown AND a verification ID is present
             if (response && response.id) {
                console.log(`Sinch verification initiated successfully for ${phoneNumber}. Verification ID: ${response.id}`);
                 // Optionally return verificationId if needed for reporting later
                 res.status(200).json({ success: true, message: 'OTP sent successfully.', verificationId: response.id });
             } else {
                 // Handle cases where Sinch might not throw an error but indicates failure in the response
                 console.error(`Sinch verification initiation failed for ${phoneNumber}, unexpected response:`, response);
                 res.status(500).json({ error: 'Failed to initiate OTP sending via Sinch.' });
             }
    
        } catch (error) {
            console.error(`Error requesting OTP for ${phoneNumber}:`, error);
    
            // --- Enhanced Error Handling ---
            // Attempt to parse specific Sinch errors (structure depends on SDK)
            let errorMessage = 'Failed to send OTP.';
            let statusCode = 500;
    
            // Check if it looks like a Sinch API error (structure may vary)
            if (error && typeof error === 'object' && 'response' in error && error.response && error.response.data) {
                 const sinchError = error.response.data.error;
                 errorMessage = `Sinch Error: ${sinchError?.message || 'Unknown Sinch Error'}`;
                 statusCode = error.response.status || 500;
                 console.error('Sinch API Error Details:', sinchError);
                 // Add specific handling based on sinchError.code if needed
            } else if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) {
                 errorMessage = 'Service temporarily unavailable (Database). Please try again later.';
                 statusCode = 503; // Service Unavailable
            } else if (error.message === 'Failed to generate secure OTP.') {
                errorMessage = 'Internal server error during OTP generation.';
                statusCode = 500;
            } else {
                // Generic internal error
                errorMessage = 'Internal server error.';
                statusCode = 500;
            }
             res.status(statusCode).json({ error: errorMessage });
        }
    });
    • Validation: Basic check for phone number format (E.164 recommended).
    • Rate Limiting: Applies a stricter rate limit specifically to this endpoint.
    • OTP Generation: Calls the secure generateOtp helper.
    • Redis Storage: Stores the OTP using the phone number as part of the key (otp:+1234567890). EX sets the expiration time atomically.
    • Sinch Call: Uses verificationService.startSms. Includes important comments advising verification of the payload structure and success response against current Sinch SDK documentation.
    • Response Handling: Checks the Sinch response (assuming response.id indicates success, but requires verification). Includes improved error handling trying to distinguish Sinch API errors from Redis errors or other internal issues.
  3. Create Verify OTP Endpoint (/api/otp/verify): This endpoint receives the phone number and the OTP entered by the user, compares it with the value in Redis, and returns the verification result.

    Add this route handler in server.js:

    javascript
    // server.js (add this route)
    
     // Rate limiting specific to OTP verification attempts
    const otpVerifyLimiter = rateLimit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 10, // Limit each IP to 10 verify attempts per 15 minutes (adjust as needed)
        standardHeaders: true,
        legacyHeaders: false,
        message: 'Too many verification attempts, please try again later.',
    });
    
    
    app.post('/api/otp/verify', otpVerifyLimiter, async (req, res) => {
        const { phoneNumber, otp } = req.body;
         // const { verificationId } = req.body; // Include if reporting verification to Sinch
    
        // 1. Validate Input
        if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
            return res.status(400).json({ error: 'Invalid phone number format.' });
        }
        if (!otp || !/^\d{6}$/.test(otp)) { // Assuming 6-digit OTP
            return res.status(400).json({ error: 'Invalid OTP format. Must be 6 digits.' });
        }
    
        const redisKey = `otp:${phoneNumber}`;
    
        try {
            // 2. Retrieve OTP from Redis
            const storedOtp = await redisClient.get(redisKey);
    
            // 3. Check if OTP exists (not expired or invalid number/key)
            if (!storedOtp) {
                console.warn(`Verification attempt failed for ${phoneNumber}: OTP not found in Redis or expired.`);
                // Keep error generic for the client
                return res.status(400).json({ error: 'Invalid or expired OTP.' });
            }
    
            // 4. Compare submitted OTP with stored OTP (use simple equality)
            if (storedOtp === otp) {
                console.log(`OTP verified successfully for ${phoneNumber}`);
    
                // Success! OTP matches. Delete from Redis immediately to prevent reuse.
                await redisClient.del(redisKey);
    
                // --- Optional: Report Verification to Sinch ---
                // This step might be required depending on your Sinch setup/plan for billing or analytics.
                // It often involves using the verification ID returned from the 'startSms' call.
                /*
                if (verificationId) {
                    try {
                        // --- IMPORTANT: Verify Sinch SDK Payload Structure ---
                        // The exact structure for reporting verification might differ.
                        // Consult the official @sinch/verification SDK documentation for the
                        // 'reportSms' method (or equivalent) and its required payload.
                        // The structure below is a hypothetical example.
                        const reportRequest = {
                             reportSmsVerificationRequest: {
                                 verificationReportRequest: {
                                     sms: { code: otp }
                                 }
                             },
                             id: verificationId // Use the ID from the start request response
                         };
                        await verificationService.reportSms(reportRequest);
                        console.log(`Reported successful verification to Sinch for ID: ${verificationId}`);
                    } catch(reportError) {
                         // Log the reporting error, but usually don't fail the user's verification flow
                         console.error(`Failed to report verification to Sinch for ${phoneNumber} (ID: ${verificationId}):`, reportError);
                    }
                }
                */
                // --- End Optional Sinch Report
    
    
                // --- Session Management TODO ---
                // IMPORTANT: Successful OTP verification alone doesn't log the user in.
                // You need to implement session management here. This typically involves:
                // 1. Finding/Creating a user record in your database associated with 'phoneNumber'.
                // 2. Marking the phone number as verified in the database.
                // 3. Generating a session token (e.g., JWT) or setting a session cookie.
                // Consider using libraries like NextAuth.js (frontend/backend) or express-session (backend).
                // For now, just return success.
                return res.status(200).json({ success: true, message: 'OTP verified successfully.' });
    
            } else {
                // Failure: OTP does not match
                console.warn(`Verification attempt failed for ${phoneNumber}: Incorrect OTP submitted.`);
                // Optional: Implement attempt tracking in Redis to lock out after N failures for a specific number
                // Keep error generic for the client
                return res.status(400).json({ error: 'Invalid or expired OTP.' });
            }
    
        } catch (error) {
            console.error(`Error verifying OTP for ${phoneNumber}:`, error);
            if (error.code === 'CONNECTION_BROKEN' || (error.message && error.message.includes('Redis connection'))) {
                 res.status(503).json({ error: 'Service temporarily unavailable (Database). Please try again later.' });
            } else {
                res.status(500).json({ error: 'Internal server error.' });
            }
        }
    });
    • Validation: Checks both phone number and OTP format.
    • Rate Limiting: Applies rate limiting to verification attempts.
    • Redis Retrieval: Fetches the expected OTP using the phone number key.
    • Comparison: Checks if the fetched OTP exists and matches the submitted OTP.
    • Cleanup: If verification is successful, the OTP must be deleted from Redis (redisClient.del) to prevent reuse.
    • Sinch Reporting (Optional): Includes a commented-out section for reporting back to Sinch, with a strong warning to verify the SDK method and payload structure.
    • Session Management TODO: Contains an expanded comment clarifying why session management is the necessary next step after successful verification and suggests common approaches.
    • Success/Failure: Returns appropriate JSON responses. Keeps failure messages generic (Invalid or expired OTP.).
  4. Restart Backend: Stop (Ctrl+C) and restart the backend server (npm start) to load the new routes. Test the endpoints using Postman or curl:

    • Request OTP: POST http://localhost:4000/api/otp/request with JSON body:

      json
      { "phoneNumber": "+1234567890" }

      (use a real phone number you can receive SMS on). Check your phone for the OTP and the console logs.

    • Verify OTP: POST http://localhost:4000/api/otp/verify with JSON body:

      json
      { "phoneNumber": "+1234567890", "otp": "123456" }

      (replace with the OTP you received). Check the response and logs.


3. Setting Up the Frontend (Next.js)

Now, let's create the Next.js application that provides the user interface for SMS authentication. This frontend will handle phone number input, OTP request submission, and verification code entry with real-time feedback.

Steps:

  1. Create Next.js App: Navigate outside your backend directory and run:

    bash
    npx create-next-app@latest sinch-otp-frontend
    # Choose options: TypeScript? (Yes), ESLint? (Yes), Tailwind? (No - for simplicity), `src/` dir? (Yes/No), App Router? (Yes - recommended), Import alias? (@/*)
    cd sinch-otp-frontend
  2. Install Dependencies:

    bash
    npm install axios # Or use built-in fetch
  3. Create .env.local File: In the sinch-otp-frontend root, create .env.local. This is for client-side environment variables.

    dotenv
    # .env.local
    
    # Must start with NEXT_PUBLIC_ to be exposed to the browser
    NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
    • NEXT_PUBLIC_API_BASE_URL: The base URL for your backend API. The NEXT_PUBLIC_ prefix makes it available in the browser.
  4. Modify the Home Page (app/page.tsx or pages/index.tsx): Replace the content of your main page file with a basic structure containing forms for requesting and verifying OTPs.

    (Example using App Router app/page.tsx and useState)

    typescript
    // app/page.tsx
    'use client'; // Required for useState and event handlers in App Router
    
    import { useState, FormEvent } from 'react';
    import axios from 'axios'; // Or use fetch
    
    const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
    
    export default function HomePage() {
      const [phoneNumber, setPhoneNumber] = useState('');
      const [otp, setOtp] = useState('');
      const [isLoading, setIsLoading] = useState(false);
      const [message, setMessage] = useState('');
      const [error, setError] = useState('');
      const [showOtpForm, setShowOtpForm] = useState(false);
      // Optional: Store verification ID if needed for reporting back to Sinch
      // const [verificationId, setVerificationId] = useState<string | null>(null);
    
      const handleRequestOtp = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setIsLoading(true);
        setMessage('');
        setError('');
    
        if (!API_BASE_URL) {
           setError('API URL not configured. Check .env.local.');
           setIsLoading(false);
           return;
        }
    
        try {
          console.log(`Requesting OTP for ${phoneNumber} at ${API_BASE_URL}/otp/request`);
          const response = await axios.post(`${API_BASE_URL}/otp/request`, {
            phoneNumber,
          });
    
          // Handle success
          if (response.data.success) {
            setMessage('OTP sent successfully! Please check your phone.');
            setShowOtpForm(true);
            // Store verificationId if backend sends it and you need it later
            // if (response.data.verificationId) {
            //   setVerificationId(response.data.verificationId);
            // }
          } else {
             // This case might not be reached if backend uses proper HTTP status codes for errors
             setError(response.data.error || 'Failed to request OTP.');
          }
    
        } catch (err: any) {
          console.error('Request OTP error:', err);
          // Extract error message from backend response if available
          const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.';
          setError(`Error: ${errorMsg}`);
          setShowOtpForm(false); // Hide OTP form on error
        } finally {
          setIsLoading(false);
        }
      };
    
      const handleVerifyOtp = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        setIsLoading(true);
        setMessage('');
        setError('');
    
         if (!API_BASE_URL) {
           setError('API URL not configured. Check .env.local.');
           setIsLoading(false);
           return;
        }
    
        try {
           console.log(`Verifying OTP ${otp} for ${phoneNumber} at ${API_BASE_URL}/otp/verify`);
          const response = await axios.post(`${API_BASE_URL}/otp/verify`, {
            phoneNumber,
            otp,
            // Include verificationId if you stored it and need to send it for reporting
            // verificationId: verificationId
          });
    
          if (response.data.success) {
            setMessage('Phone number verified successfully!');
            setShowOtpForm(false); // Hide OTP form after success
            setPhoneNumber(''); // Optionally clear forms
            setOtp('');
            // TODO: Redirect user or update application state (e.g., set logged-in status)
            // This is where you'd typically handle the post-verification logic (session, redirect etc.)
          } else {
             // This case might not be reached if backend uses proper HTTP status codes for errors
            setError(response.data.error || 'Failed to verify OTP.');
          }
    
        } catch (err: any) {
          // Error handling logic now correctly inside the catch block
          console.error('Verify OTP error:', err);
          const errorMsg = err.response?.data?.error || err.message || 'An unexpected error occurred.';
          setError(`Error: ${errorMsg}`);
        } finally {
          setIsLoading(false);
        }
      };
    
      // Basic Styling (inline for simplicity, use CSS modules or Tailwind in production)
      const inputStyle = { padding: '10px', margin: '8px 0', border: '1px solid #ccc', borderRadius: '4px', width: '100%', boxSizing: 'border-box' as const };
      const buttonStyle = { padding: '12px 20px', margin: '10px 0', cursor: 'pointer', backgroundColor: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', width: '100%', fontSize: '16px' };
      const disabledButtonStyle = { ...buttonStyle, backgroundColor: '#ccc', cursor: 'not-allowed' };
      const secondaryButtonStyle = { ...buttonStyle, backgroundColor: '#aaa', marginTop: '5px' };
      const messageStyle = { marginTop: '15px', color: 'green', fontWeight: 'bold' as const };
      const errorStyle = { marginTop: '15px', color: 'red', fontWeight: 'bold' as const };
      const containerStyle = { padding: '20px', fontFamily: 'Arial, sans-serif', maxWidth: '400px', margin: '40px auto', border: '1px solid #eee', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' };
    
      return (
        <div style={containerStyle}>
          <h1>Sinch OTP Verification</h1>
    
          {!showOtpForm ? (
            <form onSubmit={handleRequestOtp}>
              <h2>Step 1: Request OTP</h2>
              <label htmlFor="phoneNumber">Phone Number (E.164 format):</label>
              <input
                id="phoneNumber"
                type="tel"
                value={phoneNumber}
                onChange={(e) => setPhoneNumber(e.target.value)}
                placeholder="+1234567890"
                required
                style={inputStyle}
              />
              <button type="submit" disabled={isLoading} style={isLoading ? disabledButtonStyle : buttonStyle}>
                {isLoading ? 'Sending...' : 'Send OTP'}
              </button>
            </form>
          ) : (
            <form onSubmit={handleVerifyOtp}>
              <h2>Step 2: Verify OTP</h2>
              <p>Enter the OTP sent to {phoneNumber}</p>
              <label htmlFor="otp">One-Time Password:</label>
              <input
                id="otp"
                type="text"
                value={otp}
                onChange={(e) => setOtp(e.target.value)}
                placeholder="123456"
                required
                maxLength={6}
                pattern="\d{6}"
                style={inputStyle}
              />
              <button type="submit" disabled={isLoading} style={isLoading ? disabledButtonStyle : buttonStyle}>
                {isLoading ? 'Verifying...' : 'Verify OTP'}
              </button>
              <button type="button" onClick={() => setShowOtpForm(false)} style={secondaryButtonStyle} disabled={isLoading}>
                Change Phone Number
              </button>
            </form>
          )}
    
          {message && <p style={messageStyle}>{message}</p>}
          {error && <p style={errorStyle}>{error}</p>}
        </div>
      );
    }
    • 'use client': Necessary for using hooks (useState) and event handlers in Next.js App Router components.
    • State: Manages phone number, OTP input, loading status, messages, errors, and form visibility.
    • API URL: Reads the backend URL from the environment variable. Includes a check.
    • handleRequestOtp: Sends the phone number to the backend /api/otp/request endpoint. Handles success and error responses, updating the UI state. Shows the OTP form on success.
    • handleVerifyOtp: Sends the phone number and OTP to the backend /api/otp/verify endpoint. Handles success (clears form, shows success message, TODO for next steps) and errors.
    • Error Handling: Uses try...catch and attempts to parse error messages from the backend response (err.response?.data?.error).
    • Basic Form Structure: Provides simple HTML forms with input fields and submit buttons. Includes basic inline styling for demonstration.
    • Conditional Rendering: Shows either the phone number input form or the OTP input form based on the showOtpForm state.
  5. Run the Frontend:

    bash
    npm run dev

    Open your browser to http://localhost:3000. You should see the OTP verification form.

  6. Test the Complete Flow:

    • Enter your phone number in E.164 format (e.g., +1234567890).
    • Click "Send OTP" - you should receive an SMS with the OTP code.
    • Enter the received OTP code in the verification form.
    • Click "Verify OTP" - you should see a success message if the code is correct.

Congratulations! You now have a working SMS OTP verification system using Sinch, Node.js, and Next.js. This implementation provides a solid foundation for building secure phone-based authentication into your applications.