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.
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.
User sends SMS → Vonage receives it → Vonage POSTs to your webhook → Backend broadcasts via WebSocket → Frontend displays message
Outbound SMS
User 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:
Package
Purpose
express
Web framework for routing and middleware
dotenv
Loads environment variables from a .env file
@vonage/server-sdk
Vonage Node.js SDK (version 3.24.x) for sending SMS
body-parser
Parses incoming JSON and URL-encoded request bodies
socket.io
Enables WebSocket communication (version 4.8.1)
cors
Enables 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.jsrequire('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 = newVonage({
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 startupif (!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);
constPORT = process.env.PORT || 3001;
// Ensure FRONTEND_URL is set in your .env for CORSconst frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; // Default for Vite dev
app.use(cors({
origin: frontendUrl, // Allow requests only from your frontend URLmethods: ['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: newDate().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 = newServer(server, {
cors: {
origin: frontendUrl, // Allow connections only from your frontend URLmethods: ['GET', 'POST']
},
pingTimeout: 60000, // Increase timeout for unstable connectionspingInterval: 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 scenariosfunctionbroadcastSms(messageData) {
io.emit('sms_received', messageData); // Event name can be customizedconsole.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 errorreturn res.status(200).end();
}
const messageData = {
from: msisdn, // The sender's numberto: to, // Your Vonage numbertext: text, // The message contentmessageId: messageId, // Unique message ID from Vonagetimestamp: timestamp, // Timestamp from Vonagedirection: 'inbound'// Mark message direction
};
// Broadcast the received message via WebSocketbroadcastSms(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;
constfrom = process.env.VONAGE_NUMBER; // Your Vonage numberif (!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 validationconst 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(
newSMS(
{
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 UIconst sentMessageData = {
from: from,
to: to,
text: text,
messageId: resp.messageUuid, // Use the UUID returned by Vonagetimestamp: newDate().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 availableif (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 exceededconst 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.
.env
# 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:
.env
# 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 Numbers → Buy 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 Applications → Create 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 Settings → Default 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:
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:
Text
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:
Problem
Solution
No webhook received
Verify the URL in Vonage matches ngrok's HTTPS URL exactly. Check that your server is running.
404 Not Found
Verify the path /webhooks/inbound-sms matches your Express route.
500 Internal Server Error
Check backend logs for errors. Verify environment variables are set.
Ngrok connection refused
Ensure 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.
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):
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.
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 Failure
Check These Items
Inbound message not appearing
Verify webhook URL in Vonage. Check backend logs. Confirm WebSocket connected in UI.
Outbound message not sent
Check backend logs for Vonage API errors. Verify credentials. Confirm account has credit.
Messages appear in UI but not on phone
Check Vonage Dashboard logs. Verify recipient number format.
WebSocket disconnected
Check 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-validatorconst { 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 minutemax: 10, // Limit each IP to 10 requests per minutemessage: '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:
Extract the token from the Authorization: Bearer <token> header
Decode the JWT using your signature secret
Verify the payload_hash (SHA-256 hash of the webhook payload) to prevent replay attacks
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.
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).
// 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:webname:sms-backendenv:noderootDir:backend# Specify the subdirectoryplan:free# Or your desired plan (note: free tier has inactivity shutdowns)buildCommand:npminstallstartCommand:npmrunstartenvVars:# Use Node.js 22 (20.19+ or 22.12+ required for Vite 7.0)-key:NODE_VERSIONvalue:22# Reference secrets stored in Render Dashboard-key:VONAGE_API_KEYfromSecret:vonage_api_key-key:VONAGE_API_SECRETfromSecret:vonage_api_secret-key:VONAGE_APPLICATION_IDfromSecret:vonage_app_id-key:VONAGE_NUMBERfromSecret:vonage_number# Store private key as a secret file in Render-key:VONAGE_PRIVATE_KEY_PATHvalue:/etc/secrets/private.key# Path for Render secret file# Automatically use the deployed frontend URL-key:FRONTEND_URLfromService:type:webname:sms-frontendproperty:url# Frontend Service (Static Site)-type:webname:sms-frontendenv:staticrootDir:frontend# Specify the subdirectoryplan:free# Or your desired planbuildCommand:npminstall&&npmrunbuildstaticPublishPath:dist# Directory containing build outputenvVars:# Automatically use the deployed backend URL-key:VITE_BACKEND_URLfromService:type:webname:sms-backendproperty:urlroutes:# Standard SPA rewrite rule for client-side routing-type:rewritesource:/*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:
Check the Logs tab for each service to ensure they started successfully
Visit the frontend URL and verify the UI loads
Test the /health endpoint on the backend: https://sms-backend-xxxx.onrender.com/health
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).
Ensure backend responds within 1 second. Move slow operations to background jobs.
Messages Not Sending:
Problem
Cause
Solution
"Configuration error"
Missing credentials
Verify VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID, and VONAGE_PRIVATE_KEY_PATH in environment.
"Invalid number"
Wrong format
Use E.164 format: +[country code][number].
"Authentication failed"
Wrong credentials
Regenerate credentials in Vonage Dashboard.
"Quota exceeded"
No credit
Add credit to Vonage account.
Number not linked
Configuration issue
Link 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.
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.
Use the Vonage Messages API and a Node.js backend with Express to handle incoming webhooks. Socket.IO enables real-time updates from the backend to the React frontend whenever a new SMS arrives at your Vonage number.
The Vonage Messages API is the core component for sending and receiving SMS messages within your application. It provides the functionality to handle both inbound and outbound SMS communication.
Vonage uses webhooks to deliver inbound SMS messages to your application in real-time. When an SMS is sent to your Vonage number, Vonage sends an HTTP POST request to your specified webhook URL containing the message data.
Ngrok is essential during local development to expose your local server to the internet, allowing Vonage to send webhooks to your backend. In a production deployment, ngrok is replaced by your server's public URL.
Create an API endpoint in your Node.js backend (e.g., /send-sms) that uses the Vonage Messages API. Your React frontend can then make POST requests to this endpoint with the recipient's number and message text.
Socket.IO enables real-time, bidirectional communication between the Node.js backend and the React frontend. It's how incoming SMS messages are instantly displayed in the web app and provides immediate feedback for outbound messages.
Storing sensitive information like API keys directly in your code is a security risk. Environment variables (.env files) keep these credentials separate from your codebase and prevent them from being accidentally committed to version control.
In your Vonage application dashboard, navigate to your application settings. Under the "Messages" capability, set the "Inbound URL" to your public backend URL (e.g., your ngrok URL during development) followed by /webhooks/inbound-sms.
The private key file (private.key) authenticates your application with the Vonage API. It's crucial to keep this file secure and never commit it to version control. Use environment variables or secure file storage services for deployment.
Yes, Render is well-suited for deploying both the Node.js backend and React frontend. The article provides a complete render.yaml example to configure both services, including setting environment variables and handling frontend routing.
The provided instructions guide you through setting up a Node.js/Express backend, a React/Vite frontend, and configuring Vonage to connect them. Key technologies include the Vonage Messages API, Socket.IO for real-time updates, and ngrok for local testing.
The core project focuses on SMS. If you intend to incorporate voice features later, such as click-to-call or voice notifications, you'll need to enable "Voice" in your Vonage application settings and integrate the Vonage Voice API into your application.
Key practices include using environment variables for credentials, never committing .env or private.key to Git, validating user input, implementing rate limiting to prevent abuse, and using signed webhooks to verify requests from Vonage.
Verify your Vonage webhook URL matches your backend, ensure your backend server is running, check for firewalls, consult the Vonage status page, review Vonage logs for delivery errors, and check for errors in the backend webhook handler code itself.
Double-check Vonage API credentials and number configuration, validate recipient numbers, examine backend logs for Vonage API errors, confirm sufficient Vonage account funds, and verify correct use of Vonage SDK functions.
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:
Technologies Used:
Architecture:
Data Flow:
Edge Cases:
Prerequisites:
ngrokinstalled for local development webhook testing (requires free account for authtoken)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:
mkdir vonage-sms-app cd vonage-sms-app mkdir backend cd backend2. Initialize Node.js Project:
3. Install Dependencies:
expressdotenv.envfile@vonage/server-sdkbody-parsersocket.iocors4. Create Server File:
Create a file named
server.js.// 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
.envin thebackenddirectory. Never commit this file to Git.Where to find these credentials:
Create a
.env.examplefile to track required variables:Note: Format your
VONAGE_NUMBERin E.164 format – the international phone number standard (e.g.,+14155552671).6. Add
.gitignore:Create a
.gitignorefile in thebackenddirectory: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/.envfile.2. Buy a Vonage Number: * Navigate to Numbers → Buy 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/.envasVONAGE_NUMBERin 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 Applications → Create 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.keyfile downloads. This key authenticates your application with Vonage's API. Move this file into yourbackenddirectory (or provide the correct path inVONAGE_PRIVATE_KEY_PATHin 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 yourbackend/.envfile.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 Settings → Default 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.
ngrokcreates 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:
Alternatives to ngrok: localtunnel, serveo, Cloudflare Tunnel.
1. Start Your Backend Server:
# In the backend directory node server.jsYou should see "Backend server listening on port 3001".
Common startup errors:
PORTin.envor kill the existing process.npm installto install dependencies..env.2. Start Ngrok:
Open a new terminal window and run:
ngrok http 3001 # Use the port your backend is running onNgrok displays output similar to this:
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/statusendpoint to handle delivery receipts (submitted,delivered,rejected,failedstatuses). * 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.jsis running. You should see the "Inbound SMS Webhook Received:" log message with the SMS details. * Check the terminal wherengrokis running. You should see aPOST /webhooks/inbound-sms 200 OKrequest logged.If you see the logs, your backend and webhook setup work correctly!
Troubleshooting webhooks:
/webhooks/inbound-smsmatches your Express route.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-appdirectory in your terminal.# In the vonage-sms-app directory npm create vite@latest frontend -- --template react cd frontend npm install2. Install Frontend Dependencies:
socket.io-client: The client library for Socket.IO.3. Basic Styling (Optional):
You can add some basic styles to
src/index.cssorsrc/App.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.jsxwith the following:// 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.cssand add some styles:/* 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.envand.env.examplefiles in thefrontenddirectory.7. Run Frontend Development Server:
# In the frontend directory npm run devVite 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
ngroktunneling to your backend):Expected latency:
Debugging failed tests:
FRONTEND_URLandVITE_BACKEND_URLmatch. Review CORS settings.How to Secure Your Two-Way SMS Application
.envfiles locally, platform environment variables in production) and ensure.envis in your.gitignore. Keep yourprivate.keyfile secure and ensure it's not committed to Git.joiorexpress-validator:// 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 });/send-smsendpoint usingexpress-rate-limit: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 });Authorization: Bearer <token>headerpayload_hash(SHA-256 hash of the webhook payload) to prevent replay attacksiat(issued at) timestamp claimRefer to the Vonage webhook security documentation for implementation details. Also review WebSocket security best practices for securing real-time connections.
dangerouslySetInnerHTMLwith user-provided content.corsmiddleware is configured to only allow requests from your specificFRONTEND_URL. Ensure this is correctly set in production.Error Handling and Monitoring Best Practices
try...catchblocks for the Vonage API call and logs errors to the console. For production, use a dedicated logging library (likewinstonorpino) to structure logs and send them to a logging service: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);try...catchfor thefetchcall and displays errors inline (no morealert()). Monitor WebSocket connection status and provide visual cues to the user.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:
server.jsusesprocess.env.PORT.corsusesprocess.env.FRONTEND_URL.startscript tobackend/package.json:// backend/package.json "scripts": { "start": "node server.js", // ... other scripts },npm run build) generates static files in thefrontend/distdirectory.frontend/.env(or environment variables in Render) setsVITE_BACKEND_URLto your deployed backend URL.2. Create
render.yaml(Blueprint):Create a file named
render.yamlin the root (vonage-sms-app) directory. This defines both services.# 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.htmlWhy each setting is needed:
rootDir: Points to the subdirectory containing each service's codeNODE_VERSION: 22: Ensures compatibility with Vite 7.0's ESM requirementsfromSecret: Securely references credentials stored in Render DashboardfromService: Automatically injects the deployed URL of the other serviceroutesrewrite: Ensures direct URL access works for SPAs3. Deploy to Render:
render.yaml) to a GitHub/GitLab repository.render.yaml.sms-backendservice.vonage_api_key,vonage_api_secret,vonage_app_id, andvonage_numberwith their respective values.private.keyand the path to/etc/secrets/private.key(matching theVONAGE_PRIVATE_KEY_PATHinrender.yaml). Paste the contents of your localprivate.keyfile into the value field.Verify deployment:
/healthendpoint on the backend:https://sms-backend-xxxx.onrender.com/health4. Update Vonage Webhook URL (Final Step):
sms-backend) is deployed, copy its public URL (e.g.,https://sms-backend-xxxx.onrender.com).https://sms-backend-xxxx.onrender.com/webhooks/inbound-smshttps://sms-backend-xxxx.onrender.com/webhooks/inbound-smsYour 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:
https://and path). Check server is running./webhooks/inbound-smsmatches Express route exactly.Messages Not Sending:
VONAGE_API_KEY,VONAGE_API_SECRET,VONAGE_APPLICATION_ID, andVONAGE_PRIVATE_KEY_PATHin environment.+[country code][number].WebSocket Issues:
FRONTEND_URLin backend.envmatches the actual frontend URL (includinghttp/https). Check browser console for specific CORS errors.VITE_BACKEND_URLin frontend.envpoints to your running backend.Frontend UI Not Updating:
sms_receivedand frontend listens for the same event.Deployment Issues (Render):
package.jsonscripts.startCommandinrender.yamlmatches yourpackage.jsonstart script.render.yamland secret file configuration.Next Steps: Enhancing Your Two-Way SMS Application
Immediate Improvements (1–2 hours each):
/webhooks/statusendpoint to track message delivery status (delivered,failed, etc.) and update UI with status indicators.Advanced Features (4–8 hours each):
react-hot-toast), relative timestamps ("5 minutes ago"), typing indicators, message search.Scalability Enhancements (8+ hours):
Resources for enhancements: