messaging channels

Sent logo
Sent TeamMar 8, 2026 / messaging channels / Vonage

How to Build Two-Way SMS Messaging with Vonage API, React & Node.js

A guide to building a web application for real-time, two-way SMS conversations using Vonage Messages API, React, Vite, Node.js, Express, and Socket.IO.

Learn how to build a real-time, two-way SMS messaging application using the Vonage Messages API, React, and Node.js. This comprehensive tutorial shows you how to send and receive SMS messages through a web interface with instant delivery via WebSocket connections.

This pattern works well for customer support dashboards, notification management systems, team communication tools, and any application requiring bidirectional SMS communication. The architecture separates concerns: a React frontend powered by Vite provides fast development, while a Node.js backend using Express handles Vonage webhooks and API interactions. WebSockets (socket.io) bridge the backend and frontend for real-time updates.

Estimated completion time: 90–120 minutes (excluding Vonage account setup).

Project Goals:

  • Receive inbound SMS messages sent to a Vonage number
  • Display received messages instantly on a web frontend
  • Send outbound SMS messages from the web frontend via the Vonage API
  • Provide a foundation for building more complex conversational applications

Technologies Used:

  • Vonage Messages API: Sends and receives SMS messages. Uses @vonage/server-sdk 3.24.x. Learn the basics in our Vonage SMS sending guide.
  • Node.js & Express: Backend framework to handle API requests and webhooks. Requires Node.js 20.19+ or 22.12+ (Node.js 18 reached EOL April 2025).
  • React & Vite: Frontend library and build tool for the user interface. Uses Vite 7.0 with ESM-only distribution.
  • Socket.IO: Enables real-time, bidirectional communication between server and client. Uses Socket.IO 4.8.1 (requires Node.js 14+). We chose Socket.IO over native WebSockets for its automatic reconnection, room support, and fallback mechanisms. For simpler one-way updates, Server-Sent Events (SSE) work well, but two-way messaging requires WebSockets or polling. Review WebSocket security best practices for production deployments.
  • Ngrok (for local development): Exposes your local server to the internet for Vonage webhooks. Requires free account signup for authtoken.
  • Render (example deployment): Cloud platform for deploying the backend and frontend.

Architecture:

plaintext
[User's Phone] <---- SMS ----> [Vonage Platform] <---- Webhook/API ----> [Node.js Backend (Express)]
      ^                                                                          |
      |-------------------------------- SMS -------------------------------------|
                                                                                 | (WebSocket)
                                                                                 V
                                                                      [React Frontend (Vite)] <----> [Developer/User]

Data Flow:

StepFlow Description
Inbound SMSUser sends SMS → Vonage receives it → Vonage POSTs to your webhook → Backend broadcasts via WebSocket → Frontend displays message
Outbound SMSUser types in frontend → Frontend POSTs to backend API → Backend calls Vonage API → Backend broadcasts via WebSocket → Frontend displays sent message → Vonage delivers SMS

Edge Cases:

  • WebSocket disconnection: Frontend shows "Disconnected" status. Messages sent during disconnection won't appear until reconnection.
  • No connected clients: Backend always responds to Vonage webhooks with 200 OK to prevent retries, even if no clients are connected.
  • Webhook failures: Vonage retries failed webhooks. Check the Vonage Dashboard logs for delivery attempts.

Prerequisites:

  • Node.js 20.19+ or 22.12+ and npm installed (Node.js 18 reached EOL April 2025)
  • A Vonage Developer Account (Sign up for free credit)
  • Intermediate understanding of JavaScript, React, Node.js, and REST APIs
  • ngrok installed for local development webhook testing (requires free account for authtoken)
  • A code editor (e.g., VS Code)
  • Git installed

Skill Level: Intermediate (comfortable with React hooks, Express routing, and async/await patterns). Estimated Time: 90–120 minutes for complete setup and testing.


Step 1: Set Up Your Node.js Backend Server

Set up the Node.js backend server to handle Vonage interactions and manage real-time WebSocket connections.

1. Create Project Directory:

bash
mkdir vonage-sms-app
cd vonage-sms-app
mkdir backend
cd backend

2. Initialize Node.js Project:

bash
npm init -y

3. Install Dependencies:

PackagePurpose
expressWeb framework for routing and middleware
dotenvLoads environment variables from a .env file
@vonage/server-sdkVonage Node.js SDK (version 3.24.x) for sending SMS
body-parserParses incoming JSON and URL-encoded request bodies
socket.ioEnables WebSocket communication (version 4.8.1)
corsEnables Cross-Origin Resource Sharing for frontend communication
bash
npm install express dotenv @vonage/server-sdk body-parser socket.io cors

4. Create Server File:

Create a file named server.js.

javascript
// backend/server.js
require('dotenv').config();
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const bodyParser = require('body-parser');
const cors = require('cors');
const { Vonage } = require('@vonage/server-sdk');
// Import the specific message type class (adjust path based on SDK version if needed)
const { SMS } = require('@vonage/server-sdk/dist/classes/Messages/SMS');

// -----------------------------------------------------------------------------
// Vonage Configuration
// -----------------------------------------------------------------------------
const vonage = new Vonage({
  apiKey: process.env.VONAGE_API_KEY,
  apiSecret: process.env.VONAGE_API_SECRET,
  applicationId: process.env.VONAGE_APPLICATION_ID,
  privateKey: process.env.VONAGE_PRIVATE_KEY_PATH // Path to your private key file
});

// Validate configuration on startup
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
  console.error('ERROR: VONAGE_API_KEY and VONAGE_API_SECRET must be set in environment variables.');
  process.exit(1);
}
if (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH) {
  console.error('ERROR: VONAGE_APPLICATION_ID and VONAGE_PRIVATE_KEY_PATH must be set in environment variables.');
  process.exit(1);
}

// -----------------------------------------------------------------------------
// Express App Setup
// -----------------------------------------------------------------------------
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3001;

// Ensure FRONTEND_URL is set in your .env for CORS
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; // Default for Vite dev

app.use(cors({
  origin: frontendUrl, // Allow requests only from your frontend URL
  methods: ['GET', 'POST'],
}));

app.use(bodyParser.json()); // Parse JSON bodies
app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies

// Enhanced health check endpoint
app.get('/health', (req, res) => {
  const healthCheck = {
    status: 'OK',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    vonageConfigured: !!(process.env.VONAGE_API_KEY && process.env.VONAGE_APPLICATION_ID)
  };
  res.status(200).json(healthCheck);
});

// -----------------------------------------------------------------------------
// WebSocket (Socket.IO) Setup
// -----------------------------------------------------------------------------
const io = new Server(server, {
  cors: {
    origin: frontendUrl, // Allow connections only from your frontend URL
    methods: ['GET', 'POST']
  },
  pingTimeout: 60000, // Increase timeout for unstable connections
  pingInterval: 25000
});

io.on('connection', (socket) => {
  console.log('Client connected:', socket.id);

  socket.on('disconnect', (reason) => {
    console.log('Client disconnected:', socket.id, '– Reason:', reason);
  });

  socket.on('error', (error) => {
    console.error('Socket error:', error);
  });

  // Optional: Handle other events from the client if needed
});

// Handle Socket.IO errors
io.engine.on('connection_error', (err) => {
  console.error('Connection error:', err);
});

// Function to emit messages to all connected clients
// Note: In production, consider using rooms for multi-user scenarios
function broadcastSms(messageData) {
  io.emit('sms_received', messageData); // Event name can be customized
  console.log('Emitted message to clients:', messageData);
}

// -----------------------------------------------------------------------------
// Vonage Webhook Endpoint (Inbound SMS)
// -----------------------------------------------------------------------------
app.post('/webhooks/inbound-sms', (req, res) => {
  console.log('Inbound SMS Webhook Received:');
  console.log('Body:', JSON.stringify(req.body, null, 2)); // Log the full body for debugging

  // Destructure expected fields. Note: Vonage payloads include more fields like type, channel, etc.
  const { msisdn, to, text, messageId, timestamp } = req.body;

  if (!msisdn || !to || !text) {
    console.error('Incomplete SMS data received');
    // Still send 200 OK to Vonage to acknowledge receipt, but log error
    return res.status(200).end();
  }

  const messageData = {
    from: msisdn,       // The sender's number
    to: to,             // Your Vonage number
    text: text,         // The message content
    messageId: messageId, // Unique message ID from Vonage
    timestamp: timestamp, // Timestamp from Vonage
    direction: 'inbound' // Mark message direction
  };

  // Broadcast the received message via WebSocket
  broadcastSms(messageData);

  // IMPORTANT: Always respond to Vonage webhooks with a 200 OK
  res.status(200).end();
});

// -----------------------------------------------------------------------------
// API Endpoint to Send Outbound SMS
// -----------------------------------------------------------------------------
app.post('/send-sms', async (req, res) => {
  const { to, text } = req.body;
  const from = process.env.VONAGE_NUMBER; // Your Vonage number

  if (!to || !text) {
    return res.status(400).json({ error: "Missing 'to' or 'text' in request body." });
  }
  if (!from) {
    console.error('VONAGE_NUMBER environment variable not set.');
    return res.status(500).json({ error: 'Server configuration error.' });
  }

  // Basic E.164 format validation
  const e164Regex = /^\+[1-9]\d{1,14}$/;
  if (!e164Regex.test(to)) {
    return res.status(400).json({
      error: 'Invalid phone number format. Use E.164 format: +[country code][number] (e.g., +12015550123).'
    });
  }

  console.log(`Attempting to send SMS from ${from} to ${to}: "${text}"`);

  try {
    const resp = await vonage.messages.send(
      new SMS(
        {
          to: to,
          from: from,
          text: text,
          // clientRef: `my-app-${Date.now()}` // Optional client reference
        }
      )
    );

    console.log('Vonage API Response:', resp); // Includes message_uuid

    // Also broadcast the sent message to update the UI
    const sentMessageData = {
      from: from,
      to: to,
      text: text,
      messageId: resp.messageUuid, // Use the UUID returned by Vonage
      timestamp: new Date().toISOString(),
      direction: 'outbound'
    };
    broadcastSms(sentMessageData);

    res.status(200).json({ success: true, messageUuid: resp.messageUuid });

  } catch (error) {
    console.error('Error sending SMS via Vonage:', error);

    // Log more detailed error if available
    if (error.response && error.response.data) {
        console.error('Vonage Error Details:', error.response.data);
    }

    // Common Vonage error codes:
    // - 1320: Invalid number format
    // - 1330: Number not reachable
    // - 9: Partner quota exceeded
    const errorMessage = error.response?.data?.title || error.message || 'Failed to send SMS.';

    res.status(500).json({ success: false, error: errorMessage, details: error.message });
  }
});

// -----------------------------------------------------------------------------
// Start Server
// -----------------------------------------------------------------------------
server.listen(PORT, () => {
  console.log(`Backend server listening on port ${PORT}`);
  console.log(`CORS enabled for origin: ${frontendUrl}`);
});

5. Create Environment File:

Create a file named .env in the backend directory. Never commit this file to Git.

dotenv
# backend/.env

# Vonage Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Or the full path to your downloaded key
# Must be in E.164 format: +[country code][number], e.g., +12015550123
VONAGE_NUMBER=YOUR_VONAGE_PHONE_NUMBER

# Server Configuration
PORT=3001

# Frontend URL (Update for development/production)
FRONTEND_URL=http://localhost:5173 # Default Vite dev server URL

Where to find these credentials:

  • API Key & Secret: Available on the main Vonage Dashboard after signup
  • Application ID: Generated when you create a Vonage Application (see Section 2)
  • Private Key: Downloaded when you create the Vonage Application (see Section 2)
  • Vonage Number: Purchase in the Dashboard under Numbers → Buy Numbers (see Section 2)

Create a .env.example file to track required variables:

dotenv
# backend/.env.example

# Vonage Credentials
VONAGE_API_KEY=
VONAGE_API_SECRET=
VONAGE_APPLICATION_ID=
VONAGE_PRIVATE_KEY_PATH=
# Must be in E.164 format: +[country code][number], e.g., +12015550123
VONAGE_NUMBER=

# Server Configuration
PORT=3001

# Frontend URL (Update for development/production)
FRONTEND_URL=http://localhost:5173

Note: Format your VONAGE_NUMBER in E.164 format – the international phone number standard (e.g., +14155552671).

6. Add .gitignore:

Create a .gitignore file in the backend directory:

text
# backend/.gitignore
node_modules
.env
*.log
private.key # Ensure your private key is not committed

Step 2: Configure Your Vonage Account for Two-Way SMS

Configure your Vonage account to connect it to your backend and enable inbound SMS webhooks. (Note: The Vonage Dashboard UI may change over time, so exact button names or locations might differ slightly.)

1. Get Vonage Credentials: * Sign up at Vonage API if you haven't already. New accounts receive free credit. * Log in to the Vonage API Dashboard. * Find your API Key and API Secret on the main dashboard page. Add these to your backend/.env file.

2. Buy a Vonage Number: * Navigate to NumbersBuy Numbers. * Select your country. * Ensure the number has SMS capability. The Voice capability is often bundled but isn't required for this SMS-only application. * Search and buy a number. Numbers typically cost $0.90–$3.00/month depending on the country. Add it to backend/.env as VONAGE_NUMBER in E.164 format. * Pricing: Outbound SMS pricing varies by destination country. Check Vonage SMS pricing for rates. US domestic SMS typically costs $0.0075–$0.0110 per message.

3. Create a Vonage Application: * Navigate to ApplicationsCreate a new application. * Give your application a name (e.g., "My Two-Way SMS App"). * Generate public and private key: Click this button. A private.key file downloads. This key authenticates your application with Vonage's API. Move this file into your backend directory (or provide the correct path in VONAGE_PRIVATE_KEY_PATH in your .env). Keep this key secure – treat it like a password! * Capabilities: * Toggle Messages ON. * Inbound URL: Leave blank for now (update during testing/deployment). * Status URL: Leave blank for now (update during testing/deployment). * Toggle Voice OFF unless you plan to add voice features later. * Click Generate new application. * Find the Application ID and add it to your backend/.env file.

4. Link Your Number: * Scroll down on the application details page to Linked numbers. * Click Link next to the Vonage number you purchased.

5. Configure API Settings (Optional but Recommended): * Navigate to API Settings from the left menu. * Under SMS SettingsDefault SMS Setting, ensure "Deliver inbound message via: Your message webhook URL defined in application settings" is selected (this is usually the default).


Step 3: Test Webhooks Locally with Ngrok

Vonage needs a publicly accessible URL to send inbound SMS webhooks. ngrok creates a secure tunnel to your local machine for development testing.

Important: Ngrok requires a free account signup to obtain an authtoken. Sign up at ngrok.com and configure your authtoken:

bash
ngrok config add-authtoken <your-auth-token>

Alternatives to ngrok: localtunnel, serveo, Cloudflare Tunnel.

1. Start Your Backend Server:

bash
# In the backend directory
node server.js

You should see "Backend server listening on port 3001".

Common startup errors:

  • Port already in use: Another process uses port 3001. Change PORT in .env or kill the existing process.
  • Module not found: Run npm install to install dependencies.
  • Missing environment variables: Check that all required variables are set in .env.

2. Start Ngrok:

Open a new terminal window and run:

bash
ngrok http 3001 # Use the port your backend is running on

Ngrok displays output similar to this:

plaintext
Session Status                online
Account                       Your Name (Plan: Free)
Version                       x.x.x
Region                        United States (us)
Forwarding                    http://xxxxxxxx.ngrok.io -> http://localhost:3001
Forwarding                    https://xxxxxxxx.ngrok.io -> http://localhost:3001

Web Interface                 http://127.0.0.1:4040

Note: The free tier includes 1 static domain, request inspection, and email support. As of 2025, ngrok provides free static domains to free users, eliminating the need to update webhook URLs on every restart.

3. Update Vonage Webhook URL: * Copy the HTTPS Forwarding URL provided by ngrok (e.g., https://xxxxxxxx.ngrok.io). * Go back to your Vonage Application settings (Applications → Your App → Edit). * In the Messages capability section: * Inbound URL: Paste the ngrok HTTPS URL followed by your webhook path: https://xxxxxxxx.ngrok.io/webhooks/inbound-sms * Status URL: For now, use the same URL: https://xxxxxxxx.ngrok.io/webhooks/inbound-sms. In production, create a separate /webhooks/status endpoint to handle delivery receipts (submitted, delivered, rejected, failed statuses). * Click Save changes.

4. Test Inbound SMS: * Send an SMS message from your personal phone to your Vonage number. * Watch the terminal where node server.js is running. You should see the "Inbound SMS Webhook Received:" log message with the SMS details. * Check the terminal where ngrok is running. You should see a POST /webhooks/inbound-sms 200 OK request logged.

If you see the logs, your backend and webhook setup work correctly!

Troubleshooting webhooks:

ProblemSolution
No webhook receivedVerify the URL in Vonage matches ngrok's HTTPS URL exactly. Check that your server is running.
404 Not FoundVerify the path /webhooks/inbound-sms matches your Express route.
500 Internal Server ErrorCheck backend logs for errors. Verify environment variables are set.
Ngrok connection refusedEnsure backend is running on the correct port before starting ngrok.

Step 4: Build Your React Frontend with Vite

Now, build the user interface for sending and receiving SMS messages in real-time.

1. Create React Project:

Navigate back to the root vonage-sms-app directory in your terminal.

bash
# In the vonage-sms-app directory
npm create vite@latest frontend -- --template react
cd frontend
npm install

2. Install Frontend Dependencies:

  • socket.io-client: The client library for Socket.IO.
bash
npm install socket.io-client

3. Basic Styling (Optional):

You can add some basic styles to src/index.css or src/App.css.

css
/* src/index.css (example additions) */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: #f4f7f6;
}

#root {
  max-width: 800px;
  margin: 40px auto;
  padding: 20px;
  background-color: #fff;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  border-radius: 8px;
}

/* Add more specific styles in App.css */

4. Implement React Component (App.jsx):

Replace the contents of src/App.jsx with the following:

jsx
// frontend/src/App.jsx
import React, { useState, useEffect, useRef, useCallback } from 'react';
import io from 'socket.io-client';
import './App.css'; // Create this file for custom styles

// Ensure this URL matches your backend (adjust for dev/prod)
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
const socket = io(BACKEND_URL, {
  reconnection: true,
  reconnectionDelay: 1000,
  reconnectionAttempts: 5
}); // Connect to the backend WebSocket

function App() {
  const [messages, setMessages] = useState([]);
  const [recipient, setRecipient] = useState(''); // Store the number to reply to
  const [newMessage, setNewMessage] = useState('');
  const [isConnected, setIsConnected] = useState(socket.connected);
  const [isSending, setIsSending] = useState(false);
  const [error, setError] = useState('');
  const messagesEndRef = useRef(null); // To auto-scroll

  // Scroll to bottom whenever messages change
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  // Handle WebSocket connection events
  useEffect(() => {
    socket.on('connect', () => {
      console.log('WebSocket Connected:', socket.id);
      setIsConnected(true);
      setError(''); // Clear any connection errors
    });

    socket.on('disconnect', (reason) => {
      console.log('WebSocket Disconnected:', reason);
      setIsConnected(false);
      if (reason === 'io server disconnect') {
        // Server disconnected, manual reconnection needed
        socket.connect();
      }
    });

    socket.on('connect_error', (err) => {
      console.error('Connection Error:', err);
      setError('Unable to connect to server. Retrying…');
      setIsConnected(false);
    });

    // Listen for incoming/outgoing SMS messages from the backend
    socket.on('sms_received', (message) => {
      console.log('Received message via WebSocket:', message);
      setMessages((prevMessages) => [...prevMessages, message]);

      // Auto-populate recipient if it's an inbound message
      // and we don't have a recipient set yet, or it's a new conversation
      if (message.direction === 'inbound' && (!recipient || recipient !== message.from)) {
         // Simple logic: reply to the last inbound sender
         setRecipient(message.from);
         console.log(`Recipient auto-set to: ${message.from}`);
      }
    });

    // Clean up listeners on component unmount
    return () => {
      socket.off('connect');
      socket.off('disconnect');
      socket.off('connect_error');
      socket.off('sms_received');
    };
  }, [recipient]); // Re-run effect if recipient changes (for auto-population logic)

  // Handle sending a message
  const handleSendMessage = useCallback(async (e) => {
    e.preventDefault(); // Prevent form submission reload

    if (!newMessage.trim() || !recipient.trim()) {
      setError('Enter a recipient phone number and a message.');
      return;
    }

    // Basic E.164 format validation
    const e164Regex = /^\+[1-9]\d{1,14}$/;
    if (!e164Regex.test(recipient)) {
      setError('Invalid phone number format. Use E.164 format: +[country code][number] (e.g., +12015550123).');
      return;
    }

    setError(''); // Clear previous errors
    setIsSending(true);
    console.log(`Sending message to ${recipient}: "${newMessage}"`);

    try {
      const response = await fetch(`${BACKEND_URL}/send-sms`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          to: recipient,
          text: newMessage,
        }),
      });

      const result = await response.json();

      if (response.ok && result.success) {
        console.log('Message sent successfully via API:', result);
        setNewMessage(''); // Clear the input field
        // Note: The message will appear in the list via the WebSocket 'sms_received' event
      } else {
        console.error('Failed to send message:', result);
        setError(`Failed to send message: ${result.error || 'Unknown error'}`);
      }
    } catch (error) {
      console.error('Network or server error sending message:', error);
      setError(`Error sending message: ${error.message}`);
    } finally {
      setIsSending(false);
    }
  }, [newMessage, recipient]); // Dependencies for useCallback

  // Format timestamp for display
  const formatTimestamp = (isoString) => {
    if (!isoString) return '';
    try {
      return new Date(isoString).toLocaleString();
    } catch (e) {
      return isoString; // Fallback
    }
  };

  // Calculate character count and SMS segments
  const charCount = newMessage.length;
  const segmentSize = 160; // Standard SMS segment size
  const segments = Math.ceil(charCount / segmentSize) || 1;

  return (
    <div className="App">
      <h1>Vonage Two-Way SMS</h1>
      <p>
        Status: <span className={isConnected ? 'connected' : 'disconnected'}>
          {isConnected ? 'Connected' : 'Disconnected'}
        </span>
      </p>

      {error && <div className="error-message">{error}</div>}

      <div className="message-list">
        {messages.length === 0 && <p className="empty-state">No messages yet. Send an SMS to your Vonage number!</p>}
        {messages.map((msg, index) => (
          <div key={msg.messageId || index} className={`message ${msg.direction}`}>
            <span className="sender">
              {msg.direction === 'inbound' ? `From: ${msg.from}` : `To: ${msg.to}`}
            </span>
            <p>{msg.text}</p>
            <span className="timestamp">{formatTimestamp(msg.timestamp)}</span>
          </div>
        ))}
        <div ref={messagesEndRef} /> {/* Anchor for scrolling */}
      </div>

      <form className="message-form" onSubmit={handleSendMessage}>
        <div className="form-group">
          <label htmlFor="recipient">Recipient:</label>
          <input
            type="tel"
            id="recipient"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
            placeholder="Enter phone number (e.g., +12015550124)"
            required
            aria-label="Recipient phone number"
          />
        </div>
        <div className="form-group">
          <label htmlFor="message-text">Message:</label>
          <textarea
            id="message-text"
            value={newMessage}
            onChange={(e) => setNewMessage(e.target.value)}
            placeholder="Type your message…"
            rows={3}
            required
            aria-label="Message content"
          />
          <div className="character-count">
            {charCount} characters | {segments} SMS {segments === 1 ? 'segment' : 'segments'}
          </div>
        </div>
        <button
          type="submit"
          disabled={!isConnected || !recipient || !newMessage || isSending}
          aria-label="Send SMS"
        >
          {isSending ? 'Sending…' : 'Send SMS'}
        </button>
      </form>
    </div>
  );
}

export default App;

5. Add CSS (App.css):

Create src/App.css and add some styles:

css
/* frontend/src/App.css */
.App {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 80px); /* Adjust based on root padding */
  max-height: 80vh; /* Limit height */
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 20px;
}

p {
  text-align: center;
  margin-bottom: 20px;
}

.connected {
  color: green;
  font-weight: bold;
}

.disconnected {
  color: red;
  font-weight: bold;
}

.error-message {
  background-color: #fee;
  border: 1px solid #f66;
  color: #c00;
  padding: 10px;
  border-radius: 4px;
  margin-bottom: 15px;
  text-align: center;
}

.message-list {
  flex-grow: 1;
  overflow-y: auto;
  border: 1px solid #eee;
  padding: 15px;
  margin-bottom: 20px;
  background-color: #fdfdfd;
  border-radius: 4px;
}

.empty-state {
  color: #888;
  text-align: center;
  font-style: italic;
}

.message {
  margin-bottom: 15px;
  padding: 10px 15px;
  border-radius: 18px;
  max-width: 70%;
  word-wrap: break-word;
}

.message.inbound {
  background-color: #e1f5fe; /* Light blue */
  margin-right: auto; /* Align left */
  border-bottom-left-radius: 4px;
}

.message.outbound {
  background-color: #dcedc8; /* Light green */
  margin-left: auto; /* Align right */
  text-align: right;
  border-bottom-right-radius: 4px;
}

.message .sender {
  display: block;
  font-size: 0.8em;
  font-weight: bold;
  color: #555;
  margin-bottom: 4px;
}

.message p {
  margin: 0;
  padding: 0;
  text-align: left; /* Keep text aligned left regardless of bubble position */
}

.message .timestamp {
  display: block;
  font-size: 0.75em;
  color: #888;
  margin-top: 5px;
}

/* Anchor for scrolling */
.message-list div[ref] {
  height: 0;
}

.message-form {
  display: flex;
  flex-direction: column;
  gap: 15px; /* Spacing between elements */
  border-top: 1px solid #eee;
  padding-top: 20px;
}

.form-group {
    display: flex;
    flex-direction: column;
}

.form-group label {
    margin-bottom: 5px;
    font-weight: bold;
    font-size: 0.9em;
    color: #333;
}

.message-form input[type="tel"],
.message-form textarea {
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 1em;
}

.message-form textarea {
  resize: vertical; /* Allow vertical resize */
}

.character-count {
  font-size: 0.85em;
  color: #666;
  margin-top: 5px;
  text-align: right;
}

.message-form button {
  padding: 12px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1em;
  transition: background-color 0.2s ease;
}

.message-form button:hover:not(:disabled) {
  background-color: #0056b3;
}

.message-form button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}

6. Configure Vite Environment Variables:

Vite uses VITE_ prefix for environment variables exposed to the frontend. Create .env and .env.example files in the frontend directory.

dotenv
# frontend/.env
VITE_BACKEND_URL=http://localhost:3001 # Your local backend URL
dotenv
# frontend/.env.example
VITE_BACKEND_URL=http://localhost:3001

7. Run Frontend Development Server:

bash
# In the frontend directory
npm run dev

Vite will start the development server, typically at http://localhost:5173. Open this URL in your browser.


Step 5: Test Your Two-Way SMS Application End-to-End

With both backend and frontend running (and ngrok tunneling to your backend):

  1. Inbound Test: Send an SMS from your phone to your Vonage number.
    • Expected: The message should appear in the React app's message list within 1–3 seconds. The "Recipient" field should auto-populate with your phone number.
  2. Outbound Test:
    • Verify the "Recipient" field contains your personal phone number (or enter it).
    • Type a message in the text area.
    • Click "Send SMS".
    • Expected: The message should appear in the React app's message list (marked as outbound) within 1 second. You should receive the SMS on your personal phone within 5–30 seconds (varies by carrier). Check the backend console for success logs from Vonage.

Expected latency:

  • Webhook delivery: 1–3 seconds from SMS sent to webhook received
  • WebSocket broadcast: <100ms from webhook to frontend display
  • SMS delivery: 5–30 seconds from API call to recipient's phone (carrier-dependent)

Debugging failed tests:

Test FailureCheck These Items
Inbound message not appearingVerify webhook URL in Vonage. Check backend logs. Confirm WebSocket connected in UI.
Outbound message not sentCheck backend logs for Vonage API errors. Verify credentials. Confirm account has credit.
Messages appear in UI but not on phoneCheck Vonage Dashboard logs. Verify recipient number format.
WebSocket disconnectedCheck FRONTEND_URL and VITE_BACKEND_URL match. Review CORS settings.

How to Secure Your Two-Way SMS Application

  • API Credentials: Never hardcode API keys, secrets, or application IDs. Use environment variables (.env files locally, platform environment variables in production) and ensure .env is in your .gitignore. Keep your private.key file secure and ensure it's not committed to Git.
  • Input Validation: Sanitize and validate input on the backend. The example includes basic E.164 validation. For production, use libraries like joi or express-validator:
javascript
// Example with express-validator
const { body, validationResult } = require('express-validator');

app.post('/send-sms', [
  body('to').matches(/^\+[1-9]\d{1,14}$/).withMessage('Invalid E.164 phone number'),
  body('text').trim().isLength({ min: 1, max: 1600 }).withMessage('Message must be 1-1600 characters'),
], async (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  // ... rest of handler
});
  • Rate Limiting: Implement rate limiting on the /send-sms endpoint using express-rate-limit:
javascript
const rateLimit = require('express-rate-limit');

const sendSmsLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 10, // Limit each IP to 10 requests per minute
  message: 'Too many SMS requests, please try again later.',
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/send-sms', sendSmsLimiter, async (req, res) => {
  // ... handler
});
  • Webhook Security: This guide skips implementing signed webhooks for brevity, but implement them for production. Vonage uses JWT (JSON Web Token) with HMAC-SHA256 signature for webhook authentication. The signature secret (minimum 32 bits recommended) is available in your Vonage Dashboard. Verify the JWT by:
    1. Extract the token from the Authorization: Bearer <token> header
    2. Decode the JWT using your signature secret
    3. Verify the payload_hash (SHA-256 hash of the webhook payload) to prevent replay attacks
    4. Check the iat (issued at) timestamp claim

Refer to the Vonage webhook security documentation for implementation details. Also review WebSocket security best practices for securing real-time connections.

  • XSS Prevention: React automatically escapes content in JSX, preventing XSS attacks. Never use dangerouslySetInnerHTML with user-provided content.
  • CORS: The cors middleware is configured to only allow requests from your specific FRONTEND_URL. Ensure this is correctly set in production.

Error Handling and Monitoring Best Practices

  • Backend: The current code includes basic try...catch blocks for the Vonage API call and logs errors to the console. For production, use a dedicated logging library (like winston or pino) to structure logs and send them to a logging service:
javascript
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
  ],
});

// Replace console.log with logger.info, console.error with logger.error
logger.info('Backend server listening on port ' + PORT);
  • Frontend: The frontend includes basic try...catch for the fetch call and displays errors inline (no more alert()). Monitor WebSocket connection status and provide visual cues to the user.
  • Vonage Errors: Common Vonage API error codes:
Error CodeMeaningSolution
1320Invalid number formatVerify E.164 format
1330Number not reachableCheck if number is active
9Partner quota exceededAdd credit to account
1001Authentication failedVerify API key and secret

How to Deploy Your Two-Way SMS App to Production (Render)

Render is a cloud platform suitable for deploying both Node.js backends and static frontends.

Note: Render's free tier has limitations including inactivity shutdowns (services spin down after 15 minutes of inactivity), 750 hours/month of runtime, and limited CPU/memory resources. For production applications with consistent traffic, upgrade to a paid plan ($7/month for Starter).

Alternative platforms: Vercel (frontend), Railway (backend), Fly.io (backend), Heroku (backend), Netlify (frontend).

1. Prepare for Deployment:

  • Backend:
    • Ensure server.js uses process.env.PORT.
    • Ensure cors uses process.env.FRONTEND_URL.
    • Add a start script to backend/package.json:
      json
      // backend/package.json
      "scripts": {
        "start": "node server.js",
        // ... other scripts
      },
  • Frontend:
    • The build command (npm run build) generates static files in the frontend/dist directory.
    • Ensure frontend/.env (or environment variables in Render) sets VITE_BACKEND_URL to your deployed backend URL.

2. Create render.yaml (Blueprint):

Create a file named render.yaml in the root (vonage-sms-app) directory. This defines both services.

yaml
# render.yaml (in root directory)
services:
  # Backend Service (Node.js)
  - type: web
    name: sms-backend
    env: node
    rootDir: backend # Specify the subdirectory
    plan: free # Or your desired plan (note: free tier has inactivity shutdowns)
    buildCommand: npm install
    startCommand: npm run start
    envVars:
      # Use Node.js 22 (20.19+ or 22.12+ required for Vite 7.0)
      - key: NODE_VERSION
        value: 22
      # Reference secrets stored in Render Dashboard
      - key: VONAGE_API_KEY
        fromSecret: vonage_api_key
      - key: VONAGE_API_SECRET
        fromSecret: vonage_api_secret
      - key: VONAGE_APPLICATION_ID
        fromSecret: vonage_app_id
      - key: VONAGE_NUMBER
        fromSecret: vonage_number
      # Store private key as a secret file in Render
      - key: VONAGE_PRIVATE_KEY_PATH
        value: /etc/secrets/private.key # Path for Render secret file
      # Automatically use the deployed frontend URL
      - key: FRONTEND_URL
        fromService:
          type: web
          name: sms-frontend
          property: url

  # Frontend Service (Static Site)
  - type: web
    name: sms-frontend
    env: static
    rootDir: frontend # Specify the subdirectory
    plan: free # Or your desired plan
    buildCommand: npm install && npm run build
    staticPublishPath: dist # Directory containing build output
    envVars:
      # Automatically use the deployed backend URL
      - key: VITE_BACKEND_URL
        fromService:
          type: web
          name: sms-backend
          property: url
    routes:
      # Standard SPA rewrite rule for client-side routing
      - type: rewrite
        source: /*
        destination: /index.html

Why each setting is needed:

  • rootDir: Points to the subdirectory containing each service's code
  • NODE_VERSION: 22: Ensures compatibility with Vite 7.0's ESM requirements
  • fromSecret: Securely references credentials stored in Render Dashboard
  • fromService: Automatically injects the deployed URL of the other service
  • routes rewrite: Ensures direct URL access works for SPAs

3. Deploy to Render:

  • Push your code (including render.yaml) to a GitHub/GitLab repository.
  • Log in to Render and create a new Blueprint Instance.
  • Connect your repository. Render will detect render.yaml.
  • Configure Secrets/Environment Variables:
    • Go to the Environment section for the sms-backend service.
    • Create Secrets for vonage_api_key, vonage_api_secret, vonage_app_id, and vonage_number with their respective values.
    • Private Key: Create a Secret File. Set the filename to private.key and the path to /etc/secrets/private.key (matching the VONAGE_PRIVATE_KEY_PATH in render.yaml). Paste the contents of your local private.key file into the value field.
  • Click Create Blueprint. Render will build and deploy both services.

Verify deployment:

  1. Check the Logs tab for each service to ensure they started successfully
  2. Visit the frontend URL and verify the UI loads
  3. Test the /health endpoint on the backend: https://sms-backend-xxxx.onrender.com/health
  4. Send a test SMS to verify end-to-end functionality

4. Update Vonage Webhook URL (Final Step):

  • Once the backend service (sms-backend) is deployed, copy its public URL (e.g., https://sms-backend-xxxx.onrender.com).
  • Go to your Vonage Application settings (Applications > Your App > Edit).
  • Update the Messages capability URLs:
    • Inbound URL: https://sms-backend-xxxx.onrender.com/webhooks/inbound-sms
    • Status URL: https://sms-backend-xxxx.onrender.com/webhooks/inbound-sms
  • Save changes.

Your application should now be live and functional! Access the frontend via its Render URL (e.g., https://sms-frontend-yyyy.onrender.com).


Common Issues and Troubleshooting Solutions

Webhooks Not Working:

SymptomSolution
No webhook receivedVerify URL in Vonage matches your deployed URL (including https:// and path). Check server is running.
404 Not FoundVerify path /webhooks/inbound-sms matches Express route exactly.
500 Internal Server ErrorCheck backend logs for errors. Verify environment variables.
Firewall blockingCheck local firewall settings or cloud provider security groups.
Vonage service issuesCheck Vonage Status Page.
Webhook timing outEnsure backend responds within 1 second. Move slow operations to background jobs.

Messages Not Sending:

ProblemCauseSolution
"Configuration error"Missing credentialsVerify VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID, and VONAGE_PRIVATE_KEY_PATH in environment.
"Invalid number"Wrong formatUse E.164 format: +[country code][number].
"Authentication failed"Wrong credentialsRegenerate credentials in Vonage Dashboard.
"Quota exceeded"No creditAdd credit to Vonage account.
Number not linkedConfiguration issueLink your number to the application in Vonage Dashboard.

WebSocket Issues:

  • CORS errors: Ensure FRONTEND_URL in backend .env matches the actual frontend URL (including http/https). Check browser console for specific CORS errors.
  • Backend URL mismatch: Ensure VITE_BACKEND_URL in frontend .env points to your running backend.
  • Server crash: WebSocket connections drop if backend crashes. Check logs and implement error handling.
  • Network instability: Unstable networks cause disconnections. Socket.IO auto-reconnects by default.

Frontend UI Not Updating:

  • WebSocket connection: Verify "Connected" status in UI. Check browser console for connection errors.
  • Event mismatch: Ensure backend emits sms_received and frontend listens for the same event.
  • State updates: Verify React state updates correctly (check with React DevTools).

Deployment Issues (Render):

  • Build failures: Check build logs for missing dependencies or syntax errors. Verify package.json scripts.
  • Start command issues: Ensure startCommand in render.yaml matches your package.json start script.
  • Environment variables missing: Verify all secrets are configured in Render Dashboard.
  • Private key path wrong: Ensure path matches between render.yaml and secret file configuration.
  • Free tier limitations: Services spin down after 15 minutes of inactivity. First request after spin-down takes 30–60 seconds.

Next Steps: Enhancing Your Two-Way SMS Application

Immediate Improvements (1–2 hours each):

  • Conversation History (Medium): Persist messages to PostgreSQL, MongoDB, or SQLite. Load history when the app starts or a user connects. Use Render's PostgreSQL add-on or Supabase.
  • Delivery Receipts (Easy): Implement /webhooks/status endpoint to track message delivery status (delivered, failed, etc.) and update UI with status indicators.
  • Better Error Messages (Easy): Display specific error messages from Vonage API instead of generic "Failed to send SMS."

Advanced Features (4–8 hours each):

  • Multiple Conversations (Hard): Organize messages by conversation thread (based on the other party's number). Add a sidebar showing all active conversations.
  • User Authentication (Medium): Implement JWT-based authentication so multiple users can access the app. Associate messages with specific users using Firebase Auth, Auth0, or Passport.js.
  • Improved UI/UX (Medium): Add loading states, toast notifications (using react-hot-toast), relative timestamps ("5 minutes ago"), typing indicators, message search.
  • Webhook Security (Medium): Implement Vonage signed webhook verification for production security.

Scalability Enhancements (8+ hours):

  • Database Integration: Move from in-memory to PostgreSQL/MongoDB for message persistence.
  • WebSocket Rooms: Use Socket.IO rooms to broadcast messages only to relevant users in multi-user scenarios.
  • Backend Architecture: Implement worker queues (Bull/BullMQ) for handling high-volume SMS sending. Use Redis for caching and session management.
  • Add Voice (Hard): Extend the application using the Vonage Voice API for click-to-call or voice notifications. Requires additional webhook endpoints and UI components.

Resources for enhancements: