code examples
code examples
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
cryptomodule. - Storing OTPs temporarily (using Redis).
- Sending OTPs to users' phone numbers via the Sinch Verification API.
- Verifying user-submitted OTPs against the stored values.
- Generating secure OTPs using Node.js's
- 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:ioredisis maintained on best-effort basis; for new projects in 2025, considernode-redisas 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:
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:1pxPrerequisites:
- 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:
-
Create Project Directory:
bashmkdir sinch-otp-backend cd sinch-otp-backend -
Initialize Node.js Project:
bashnpm init -y -
Install Dependencies:
bashnpm install express dotenv ioredis @sinch/sdk-core @sinch/verification cors express-rate-limitexpress: 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.
-
Create
.envFile: Create a file named.envin thesinch-otp-backendroot 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:3000PORT: 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.
-
Create Basic Server File (
server.js): Create a file namedserver.jsin 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 locallydotenv.config(): Must be called early to load.envvariables.- Redis Client: Initializes
iorediswith the URL from.env. Includes basic retry logic and error handling. - Sinch Client: Initializes the Sinch core client and the specific
SmsVerificationservice 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.listenreturn value is stored inserver, allowingserver.close()to be called correctly onSIGTERMorSIGINT.
-
Add Start Script to
package.json: Openpackage.jsonand add/ensure you have astartscript: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": "..." } } -
Initial Run: Make sure your Redis server is running. Then, start the backend:
bashnpm startYou should see
Backend server running on http://localhost:4000andConnected to Redisif everything is configured correctly. Accesshttp://localhost:4000/healthin 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:
-
Create OTP Generation Logic: Use Node.js's built-in
cryptomodule 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.randomIntwhich is suitable for security-sensitive values like OTPs. - Padding: Ensures the OTP is always the desired length (e.g.,
012345instead of12345).
- Security: Uses
-
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.jsunder 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
generateOtphelper. - Redis Storage: Stores the OTP using the phone number as part of the key (
otp:+1234567890).EXsets 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.idindicates success, but requires verification). Includes improved error handling trying to distinguish Sinch API errors from Redis errors or other internal issues.
-
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.).
-
Restart Backend: Stop (
Ctrl+C) and restart the backend server (npm start) to load the new routes. Test the endpoints using Postman orcurl:-
Request OTP:
POST http://localhost:4000/api/otp/requestwith 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/verifywith 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:
-
Create Next.js App: Navigate outside your backend directory and run:
bashnpx 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 -
Install Dependencies:
bashnpm install axios # Or use built-in fetch -
Create
.env.localFile: In thesinch-otp-frontendroot, 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/apiNEXT_PUBLIC_API_BASE_URL: The base URL for your backend API. TheNEXT_PUBLIC_prefix makes it available in the browser.
-
Modify the Home Page (
app/page.tsxorpages/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.tsxanduseState)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/requestendpoint. 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/verifyendpoint. Handles success (clears form, shows success message, TODO for next steps) and errors.- Error Handling: Uses
try...catchand 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
showOtpFormstate.
-
Run the Frontend:
bashnpm run devOpen your browser to
http://localhost:3000. You should see the OTP verification form. -
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.
- Enter your phone number in E.164 format (e.g.,
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.