code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Build SMS Marketing Campaigns with Twilio, Node.js, React & Vite [2025]

Step-by-step guide to building SMS marketing campaigns using Twilio API, Node.js Express backend, and React/Vite frontend. Includes A2P 10DLC compliance, E.164 validation, and security best practices.

Build a marketing campaign web application that sends SMS messages using Twilio. You'll create a React frontend with Vite that interacts with a secure Node.js backend using the Twilio API. Learn how to implement E.164 phone validation, handle A2P 10DLC compliance, and secure your SMS integration for production use.

Project Overview and Goals

What You're Building:

A two-part application:

  1. A React frontend (built with Vite) providing a simple form to input a recipient phone number and message body.
  2. A Node.js backend (using Express) with a single API endpoint that receives requests from the frontend and uses the Twilio Node.js helper library to send an SMS message.

Problem You'll Solve:

This setup provides a secure and basic way to integrate SMS functionality into a web application. It ensures that sensitive Twilio credentials (Account SID and Auth Token) never reach the client-side browser, mitigating security risks.

Technologies You'll Use:

  • Node.js: A JavaScript runtime for building the backend server.
  • Express: A minimal and flexible Node.js web application framework for creating the API endpoint.
  • Vite: A modern frontend build tool providing a fast development experience.
  • React: A popular JavaScript library for building user interfaces.
  • Twilio: A communication platform as a service (CPaaS) used here for its Programmable Messaging API to send SMS.
  • dotenv: A Node.js module to load environment variables from a .env file, keeping credentials secure.
  • cors: Node.js middleware to enable Cross-Origin Resource Sharing, necessary for the frontend (running on a different port) to communicate with the backend API during development.

System Architecture:

text
+-----------------+      HTTP Request      +-------------------+      Twilio API Call      +--------+
|  Vite/React UI  | ---------------------> | Node.js/Express API | -------------------------> | Twilio |
| (User Input Form)|      (Send SMS Data)   |   (/api/send-sms)   | (AccountSID, AuthToken,  |  (SMS  |
+-----------------+                      +-------------------+   |  To, From, Body)         | Sent)  |
       ^                                          |                                          |
       |         HTTP Response (Success/Error)     |                                          |
       +-------------------------------------------+                                          |
                                                                                             |
+---------------------+      +-------------------------+                                     |
| Environment Variables |      | Environment Variables   |                                     |
| (backend/.env file) |      | (frontend/.env file)    |                                     |
| - TWILIO_ACCOUNT_SID|      | - VITE_API_URL          |                                     |
| - TWILIO_AUTH_TOKEN |      +-------------------------+                                     |
| - TWILIO_PHONE_NUMBER|                                                                     |
+---------------------+                                                                      |

Prerequisites:

  • Node.js and npm (or yarn): Ensure Node.js v18 LTS or later is installed (v20 LTS recommended as of 2025). Verify with node -v and npm -v. Earlier versions may work but are not actively supported.
  • Twilio Account: Sign up for a free Twilio account at twilio.com.
  • Twilio Phone Number: Purchase an SMS-enabled phone number from the Twilio Console (Phone Numbers > Buy a Number).
  • Twilio Credentials: Locate your Account SID (starts with "AC") and Auth Token in the Twilio Console dashboard.
  • Verified Personal Phone Number (for Trial Accounts): If using a Twilio trial account, verify your personal phone number in the Twilio Console (Phone Numbers > Verified Caller IDs). Trial accounts can only send messages to verified numbers – this security measure prevents abuse.

Final Outcome:

A functional web form where you can enter a phone number and message, click "Send," and have an SMS delivered via Twilio, with basic feedback provided in the UI.

1. Set Up Your Project Structure

Create a main project directory containing separate backend and frontend subdirectories.

1.1. Create the Project Structure:

Open your terminal and run:

bash
mkdir twilio-sms-app
cd twilio-sms-app
mkdir backend
mkdir frontend

1.2. Set Up the Backend (Node.js/Express):

Navigate into the backend directory and initialize a new Node.js project.

bash
cd backend
npm init -y

This creates a package.json file. Now install the necessary dependencies: Express for the server, the Twilio helper library, dotenv for environment variables, and cors for handling cross-origin requests during development.

bash
npm install express twilio dotenv cors

1.3. Create Backend Configuration Files:

  • .env (Create this file in the backend directory): This file stores your sensitive Twilio credentials. Never commit this file to version control.

    dotenv
    # backend/.env
    TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx
    TWILIO_PHONE_NUMBER=+15551234567

    Replace the placeholder values with your actual Twilio Account SID, Auth Token, and Twilio Phone Number (in E.164 format).

  • .gitignore (Create this file in the backend directory): Prevent sensitive files and unnecessary directories from being tracked by Git.

    text
    # backend/.gitignore
    node_modules
    .env
  • server.js (Create this file in the backend directory): This contains your backend server code. Add the logic in the next section.

    javascript
    // backend/server.js
    // Basic structure – expand in later steps
    require('dotenv').config(); // Load environment variables from .env file
    const express = require('express');
    const twilio = require('twilio');
    const cors = require('cors');
    
    const app = express();
    const port = process.env.PORT || 3001; // Use port 3001 for the backend
    
    // Middleware
    app.use(cors()); // Enable CORS for all origins (adjust for production)
    app.use(express.json()); // Parse JSON request bodies
    
    // API Endpoint placeholder
    app.post('/api/send-sms', (req, res) => {
      // Logic will go here
      res.send('SMS endpoint hit');
    });
    
    // Start the server
    app.listen(port, () => {
      console.log(`Backend server listening on port ${port}`);
    });

1.4. Set Up the Frontend (Vite/React):

Navigate back to the root project directory (twilio-sms-app) and then into the frontend directory. Use Vite to scaffold a new React project.

bash
cd .. # Ensure you are in the root twilio-sms-app directory
cd frontend
npm create vite@latest . -- --template react

(Choose react and javascript or typescript if prompted, though this guide uses JavaScript)

This command scaffolds a React project in the current (frontend) directory. Now install the dependencies:

bash
npm install

1.5. Create Frontend Configuration Files:

  • .env (Create this file in the frontend directory): This file stores frontend-specific environment variables, like the backend API URL.

    dotenv
    # frontend/.env
    VITE_API_URL=http://localhost:3001/api/send-sms

    Vite automatically loads variables prefixed with VITE_ from this file.

  • .gitignore (Create or update this file in the frontend directory): Ensure the frontend .env file is also ignored by Git.

    text
    # frontend/.gitignore
    node_modules
    dist
    .env* # Ignore .env, .env.development, .env.production etc.
    *.local
    # ... other default vite ignores

    (Note: Vite's default .gitignore might already cover .env*. Ensure it's present.)

This sets up your React application structure within the frontend directory.

2. Build the Backend API

Build the backend API endpoint in backend/server.js that sends SMS using Twilio.

2.1. Update server.js:

Modify the backend/server.js file to include the Twilio logic within the /api/send-sms route handler.

javascript
// backend/server.js
require('dotenv').config();
const express = require('express');
const twilio = require('twilio');
const cors = require('cors');

const app = express();
const port = process.env.PORT || 3001;

// --- Environment Variable Validation ---
// Essential check: ensure credentials are loaded
if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN || !process.env.TWILIO_PHONE_NUMBER) {
  console.error('Error: Missing Twilio credentials in backend/.env file.');
  console.error('Ensure TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER are set.');
  process.exit(1); // Exit if credentials aren't found
}

// --- Twilio Client Initialization ---
// Creates the client instance needed to interact with the Twilio API.
// Uses credentials securely loaded from environment variables.
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const client = twilio(accountSid, authToken);
const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER;

// --- Middleware ---
// cors: Allows the frontend (running on a different port/origin) to make requests to this backend API.
// Important: For production, configure CORS more restrictively (e.g., allow only your frontend domain).
app.use(cors());
// express.json: Parses incoming requests with JSON payloads (like the one the frontend sends).
app.use(express.json());

// --- Logging Middleware (Optional but Recommended) ---
// Provides visibility into incoming requests for debugging.
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()}${req.method} ${req.path}`);
  next(); // Pass control to the next middleware/route handler
});

// --- API Endpoint: /api/send-sms ---
app.post('/api/send-sms', async (req, res) => {
  // --- Input Validation (Basic) ---
  // Prevents errors and potential misuse by ensuring required data is present.
  // Production apps need more robust validation (e.g., phone number format).
  const { to, body } = req.body;
  if (!to || !body) {
    console.error('Validation Error: Missing "to" or "body" in request.');
    return res.status(400).json({ success: false, message: 'Missing required fields: "to" and "body".' });
  }

  // --- Twilio API Call ---
  try {
    // async/await: Handles the asynchronous nature of the API call cleanly.
    console.log(`Attempting to send SMS to: ${to}`);
    const message = await client.messages.create({
      body: body,
      from: twilioPhoneNumber, // Your Twilio phone number from .env
      to: to                 // Recipient's phone number from the request (must be E.164 format)
    });

    console.log(`SMS sent successfully! SID: ${message.sid}`);
    // Return success: Sends a confirmation back to the frontend.
    res.status(200).json({ success: true, message: `SMS sent successfully! SID: ${message.sid}` });

  } catch (error) {
    // --- Error Handling ---
    // Catches errors from the Twilio API (e.g., invalid number, network issue)
    // and provides informative feedback to both the console and the frontend.
    console.error('Twilio API Error:', error.message);
    console.error('Error Code:', error.code);

    // Determine appropriate status code based on Twilio error if possible
    // Example: Twilio error codes often map to HTTP status codes (e.g., 21211 invalid 'To' number → 400)
    const statusCode = error.status || 500; // Use Twilio status or default to 500

    res.status(statusCode).json({
        success: false,
        message: `Failed to send SMS: ${error.message}`,
        errorCode: error.code // Include Twilio error code in response
    });
  }
});

// --- Basic Root Route (Optional) ---
app.get('/', (req, res) => {
  res.send('Twilio SMS Backend is running!');
});

// --- Start Server ---
app.listen(port, () => {
  console.log(`Backend server listening on port ${port}`);
  console.log('Ensure your backend/.env file is configured correctly.');
});

Explanation:

  1. Environment Variables: require('dotenv').config() loads variables from backend/.env. A crucial check ensures the required Twilio variables exist before proceeding.
  2. Twilio Client: twilio(accountSid, authToken) initializes the client needed to interact with the Twilio API.
  3. Middleware: cors() enables requests from the frontend. express.json() parses the incoming request body. A simple logging middleware provides visibility.
  4. API Endpoint (/api/send-sms):
    • It's an async function because the Twilio API call is asynchronous.
    • It extracts to (recipient number) and body (message text) from the request's JSON payload (req.body).
    • Basic Validation: Checks if to and body exist. Returns a 400 Bad Request if not. Note: Production apps require stricter validation (e.g., using a library like joi or checking E.164 format).
    • Twilio Call: client.messages.create() sends the SMS. It requires:
      • body: The message content.
      • from: Your Twilio phone number (loaded from .env).
      • to: The recipient's number (from the request). Must be in E.164 format (e.g., +1xxxxxxxxxx).
    • Success Response: If the API call succeeds, logs the message SID and sends a 200 OK JSON response to the frontend.
    • Error Handling: A try...catch block handles potential errors during the Twilio API call (invalid credentials, invalid 'to' number, network issues, etc.). It logs the error details and sends an appropriate error status code (using error.status if available, otherwise 500) and JSON message back to the frontend.

3. Build the Frontend UI

Create the React form in the frontend to interact with your backend API.

3.1. Update App.jsx:

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

jsx
// frontend/src/App.jsx
import React, { useState } from 'react';
import './App.css'; // You might want to add some basic styling

function App() {
  // --- State Variables ---
  // Store the user's input for the phone number and message body,
  // and track the sending status/feedback.
  const [recipient, setRecipient] = useState('');
  const [messageBody, setMessageBody] = useState('');
  const [statusMessage, setStatusMessage] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false);

  // --- Backend API URL ---
  // Reads the backend URL from environment variables (Vite).
  // Provides a fallback for local development if .env is missing.
  const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/send-sms';

  // --- Handle Form Submission ---
  const handleSubmit = async (event) => {
    event.preventDefault(); // Prevent default form submission (page reload)
    setIsLoading(true);     // Indicate loading state
    setStatusMessage('');   // Clear previous status
    setIsError(false);

    // --- Basic Frontend Validation ---
    // Provide immediate feedback without waiting for the backend.
    if (!recipient || !messageBody) {
        setStatusMessage('Enter both a recipient number and a message.');
        setIsError(true);
        setIsLoading(false);
        return; // Stop submission
    }
    // Enhanced E.164 validation (ITU-T E.164 standard)
    // Validates: '+' followed by 1-15 digits (country code + national number)
    // Reference: https://www.itu.int/rec/T-REC-E.164/en
    // For production, use libphonenumber-js for complete validation including regional rules
    if (!/^\+[1-9]\d{1,14}$/.test(recipient)) {
        setStatusMessage('Recipient number must be in E.164 format: + followed by country code and number (1-15 digits total). Example: +15551234567');
        setIsError(true);
        setIsLoading(false);
        return;
    }


    try {
      // --- Make API Request ---
      // Sends the form data to the backend endpoint using fetch.
      const response = await fetch(API_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json', // Indicate we're sending JSON
        },
        body: JSON.stringify({ to: recipient, body: messageBody }), // Send data as JSON string
      });

      // --- Handle API Response ---
      const data = await response.json(); // Parse the JSON response from the backend

      if (response.ok && data.success) {
        // response.ok: Checks if the HTTP status code indicates success (200-299).
        // data.success: Checks the custom 'success' field in the backend's JSON response.
        setStatusMessage(`Success: ${data.message}`);
        setIsError(false);
        // Optionally clear form on success
        // setRecipient('');
        // setMessageBody('');
      } else {
        // Handle backend errors (e.g., validation, Twilio API errors)
        setStatusMessage(`Error: ${data.message || 'Failed to send SMS.'}`);
        setIsError(true);
      }
    } catch (error) {
      // Handle network errors or issues reaching the backend
      console.error('Frontend Error:', error);
      setStatusMessage('Network error or backend is unreachable. Try again.');
      setIsError(true);
    } finally {
      // finally: Ensures loading state turns off regardless of success or failure.
      setIsLoading(false);
    }
  };

  // --- Render UI ---
  return (
    <div className="App">
      <h1>Send SMS via Twilio</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="recipient">Recipient Phone Number:</label>
          <input
            type="tel" // Use tel type for phone numbers
            id="recipient"
            value={recipient}
            onChange={(e) => setRecipient(e.target.value)}
            placeholder="+15551234567" // Example E.164 format
            required // Basic HTML5 validation
            disabled={isLoading} // Disable input during sending
          />
        </div>
        <div>
          <label htmlFor="messageBody">Message:</label>
          <textarea
            id="messageBody"
            value={messageBody}
            onChange={(e) => setMessageBody(e.target.value)}
            required
            disabled={isLoading} // Disable textarea during sending
          />
        </div>
        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Sending…' : 'Send SMS'}
        </button>
      </form>

      {/* Display Status/Feedback Message */}
      {statusMessage && (
        <p className={`status-message ${isError ? 'error' : 'success'}`}>
          {statusMessage}
        </p>
      )}
    </div>
  );
}

export default App;

3.2. Basic Styling (Optional):

Add some basic styles to frontend/src/App.css for better presentation:

css
/* frontend/src/App.css */
.App {
  font-family: sans-serif;
  max-width: 500px;
  margin: 40px auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 8px;
  box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.1);
}

h1 {
  text-align: center;
  color: #333;
}

form div {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
  color: #555;
}

input[type="tel"],
textarea {
  width: 100%;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box; /* Include padding in width */
  font-size: 1rem;
}

textarea {
  height: 100px;
  resize: vertical;
}

button {
  background-color: #007bff; /* Blue */
  color: white;
  padding: 12px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  width: 100%;
  transition: background-color 0.2s ease-in-out;
}

button:hover {
  background-color: #0056b3; /* Darker blue */
}

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

.status-message {
  margin-top: 20px;
  padding: 10px;
  border-radius: 4px;
  text-align: center;
}

.status-message.success {
  background-color: #d4edda; /* Light green */
  color: #155724;      /* Dark green */
  border: 1px solid #c3e6cb;
}

.status-message.error {
  background-color: #f8d7da; /* Light red */
  color: #721c24;      /* Dark red */
  border: 1px solid #f5c6cb;
}

Explanation:

  1. State: useState hooks manage the recipient number, message body, loading state, and status message displayed to the user.
  2. API URL: Reads the backend endpoint URL from import.meta.env.VITE_API_URL (defined in frontend/.env), with a fallback to http://localhost:3001/api/send-sms.
  3. handleSubmit:
    • Prevents the default form submission behavior (page reload).
    • Sets loading state and clears previous statuses.
    • Performs basic frontend validation (checks for empty fields and simple E.164 format). Note added about regex limitations.
    • Uses fetch to make a POST request to the backend API.
    • Sends the recipient and messageBody in the request body as a JSON string.
    • Sets the Content-Type header to application/json.
    • Response Handling: Parses the JSON response from the backend. Updates the statusMessage based on whether the request was successful (response.ok and data.success) or resulted in an error.
    • Error Handling: A catch block handles network errors (e.g., backend server not running).
    • finally: Resets the loading state regardless of the outcome.
  4. JSX Structure: Renders a standard HTML form with input fields bound to the state variables and an onSubmit handler linked to handleSubmit. It disables inputs and the button during the loading state. A conditional paragraph (<p>) displays the statusMessage.

4. Configure Your Twilio Credentials

Obtain and securely manage your Twilio credentials.

4.1. Obtain Your Credentials:

  • Account SID & Auth Token:
    1. Log in to your Twilio Console.
    2. On the main dashboard, find your Account SID and Auth Token. Click "Show" to reveal the Auth Token.
    3. Copy these values carefully.
  • Twilio Phone Number:
    1. Navigate to Phone Numbers > Manage > Active Numbers in the Twilio Console.
    2. If you don't have one, go to Buy a Number, ensure the SMS capability is checked, search, and purchase a number.
    3. Copy the phone number exactly as shown, including the + and country code (E.164 format).

4.2. Store Credentials Securely (.env):

  • Place your Twilio credentials only in the backend/.env file:

    dotenv
    # backend/.env
    TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx
    TWILIO_PHONE_NUMBER=+1xxxxxxxxxx
  • Crucially: Add .env to your backend/.gitignore file. Never commit your .env file or hardcode credentials directly in your source code (server.js or frontend files).

  • Similarly, ensure frontend/.env (containing VITE_API_URL) is in your frontend/.gitignore.

4.3. Understand Environment Variables:

  • TWILIO_ACCOUNT_SID (Backend): Your unique account identifier.
  • TWILIO_AUTH_TOKEN (Backend): Your secret key for authenticating API requests. Treat it like a password.
  • TWILIO_PHONE_NUMBER (Backend): The E.164 formatted Twilio number used to send SMS messages.
  • VITE_API_URL (Frontend): The URL of your backend API endpoint.

5. Run and Test Your Application

Run both the backend and frontend servers to test the application. Ensure you run commands from the correct directories.

5.1. Start the Backend Server:

Open a terminal window, navigate to the project's root directory (twilio-sms-app), then into the backend directory, and run:

bash
cd backend # Make sure you are in the twilio-sms-app/backend directory
node server.js

You should see output like:

Backend server listening on port 3001 Ensure your backend/.env file is configured correctly.

If you installed nodemon (optional, npm install -D nodemon), run nodemon server.js for automatic restarts on file changes.

5.2. Start the Frontend Development Server:

Open a second terminal window, navigate to the project's root directory (twilio-sms-app), then into the frontend directory, and run:

bash
cd frontend # Make sure you are in the twilio-sms-app/frontend directory
npm run dev

Vite starts the development server, typically on port 5173 (or the next available port), and provides a local URL:

VITE vX.Y.Z ready in XXXms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose

5.3. Verify Manually:

  1. Open the local URL (e.g., http://localhost:5173/) provided by Vite in your web browser.
  2. You should see the "Send SMS via Twilio" form.
  3. Enter a valid recipient phone number in E.164 format (e.g., +15559876543).
    • Trial Account: Use the personal phone number you verified in the Twilio Console. Sending to any other number will fail.
    • Upgraded Account: Send to other numbers, but be mindful of regulations (like A2P 10DLC in the US – see Compliance section below).
  4. Enter a message in the "Message" field.
  5. Click the "Send SMS" button.
  6. Observe the status message below the form. It should indicate "Sending…", then either "Success: SMS sent successfully! SID: SMxxxxxxxx…" or an error message.
  7. Check the recipient's phone – the SMS should arrive shortly. (Trial messages include the prefix "Sent from a Twilio trial account.")
  8. Check the terminal running the backend server – you should see logs for the incoming request and the Twilio API call attempt (success or error).

5.4. Test the API Directly (Optional – curl / Postman):

Test the backend API endpoint directly.

  • Using curl: Open a terminal and run:

    bash
    curl -X POST http://localhost:3001/api/send-sms \
         -H "Content-Type: application/json" \
         -d '{"to": "+15559876543", "body": "Test message from curl"}'

    Replace +15559876543 with a valid recipient number (your verified number if on a trial account). You should get a JSON response like: {"success":true,"message":"SMS sent successfully! SID: SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} or an error JSON.

  • Using Postman: Create a new POST request to http://localhost:3001/api/send-sms. Set the Body type to raw and format to JSON. Enter the JSON payload: {"to": "+15559876543", "body": "Test from Postman"}. Send the request and observe the response.

6. Handle Errors and Understand Caveats

While the code includes basic error handling, here are common issues and considerations:

Backend (server.js):

  • Missing Credentials: The initial check catches if .env variables aren't loaded. Ensure the backend/.env file exists in the backend directory, has the correct name, and contains the right variables.
  • Twilio API Errors (Caught in try...catch):
    • Error Code 21211: Invalid 'To' Phone Number: The recipient number is likely missing, invalid, not E.164 formatted, or (if on a trial account) not your verified number. The backend returns a 400 status. Ensure the number starts with + and the country code. Reference: https://www.twilio.com/docs/api/errors/21211
    • Error Code 21408: Permission to send SMS has not been enabled: Your Twilio number doesn't have SMS capabilities. Purchase a different number with SMS enabled.
    • Error Code 21608: The 'To' phone number is not currently reachable: The number might be out of service, blocked, or on a carrier that doesn't accept your traffic.
    • Error Code 21212: Invalid 'From' Phone Number: The TWILIO_PHONE_NUMBER in backend/.env is incorrect, not SMS-enabled, or not owned by your account. Verify in Console > Phone Numbers > Manage > Active Numbers.
    • Error Code 20003: Authentication Error: Your Account SID or Auth Token in backend/.env is incorrect. Double-check them in the Twilio Console. Auth Token is case-sensitive.
    • Error Code 21610: Attempt to send to unsubscribed recipient: The recipient previously replied "STOP" to your Twilio number. They must reply "START" to re-subscribe.
    • Error Code 30007: Message filtered (carrier violation): The message content was filtered by the carrier. Ensure compliance with CTIA guidelines and avoid prohibited content (phishing, illegal content, etc.).
    • Trial Account Restriction: Sending to unverified numbers fails with Error 21211. You can only send SMS to phone numbers verified in your Twilio Console (Console > Phone Numbers > Manage > Verified Caller IDs).
  • Logging: The backend 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.

Frontend (App.jsx):

  • Network Error: If the backend server isn't running or reachable, the fetch call will fail (caught in the catch block). Ensure the backend is running and the VITE_API_URL in frontend/.env (or the fallback in App.jsx) is correct.
  • CORS Errors: If the cors() middleware is missing or misconfigured on the backend, the browser will block the fetch request. Check the browser's developer console (Network tab) for CORS errors. The provided cors() setup is generally permissive for development; restrict it in production (see Security).
  • Incorrect E.164 Format: The basic frontend check catches some errors, but ensure the number format is correct before submitting. Use a more robust validation method for production apps.
  • Backend Error Display: The frontend displays the message field from the backend's JSON error response.

General Caveats:

  • E.164 Format: Always use + followed by the country code and number (e.g., +1 for North America, +44 for UK). The ITU-T E.164 standard specifies international telephone numbers must not exceed 15 digits (including country code). Reference: https://www.itu.int/rec/T-REC-E.164/en

  • Rate Limits: Twilio imposes rate limits on sending messages. Default limits: 1 message per second per phone number for new accounts, up to 100 concurrent requests per account. Sending too rapidly results in 429 Too Many Requests errors. For high volume, use Twilio Messaging Services with verified sender profiles. Reference: https://www.twilio.com/docs/usage/webhooks/webhooks-connection-overrides#connection-overrides

  • Compliance (A2P 10DLC – US & Canada): Sending Application-to-Person (A2P) SMS messages from standard 10-digit long code phone numbers to users in the US requires A2P 10DLC registration (effective September 1, 2022). Unregistered A2P traffic faces heavy carrier filtering and delivery failures. Registration involves:

    1. Register your business/brand with The Campaign Registry (TCR)
    2. Register your messaging use case (campaign)
    3. Complete carrier vetting (typically 1-2 weeks)

    US carriers (AT&T, T-Mobile, Verizon) enforce this requirement, not Twilio. Trial accounts are exempt from A2P 10DLC registration but have sending restrictions. For production US messaging, budget time for registration before launch. Reference: https://www.twilio.com/docs/sms/a2p-10dlc

    Canada has similar requirements through the Canadian SMS Sender Registration (effective June 1, 2023). Reference: https://www.twilio.com/docs/sms/a2p/a2p-canada

  • Trial Account Limitations: Messages sent from trial accounts are prefixed with "Sent from a Twilio trial account" and can only be successfully sent to phone numbers verified in your Twilio Console. Trial accounts receive $15.50 USD in credit (as of 2025).

7. Secure Your Application

Security is paramount, especially when dealing with API keys.

  • Credential Security: Never expose TWILIO_ACCOUNT_SID or TWILIO_AUTH_TOKEN in frontend code or commit them to version control. Use environment variables on the backend (backend/.env locally, platform-specific environment variables in deployment). Treat Auth Token like a password – rotate it immediately if compromised.

  • Input Validation (Backend): The current validation is minimal. Implement robust validation on the backend:

    • Phone Number Validation: Use libphonenumber-js library (npm package libphonenumber-js) to strictly validate E.164 format and check number validity for specific regions:
      javascript
      const { parsePhoneNumber } = require('libphonenumber-js');
      try {
        const phoneNumber = parsePhoneNumber(to);
        if (!phoneNumber.isValid()) {
          return res.status(400).json({ success: false, message: 'Invalid phone number' });
        }
      } catch (error) {
        return res.status(400).json({ success: false, message: 'Phone number parsing failed' });
      }
    • Message Body Validation: Check length (SMS segments: 160 GSM-7 characters or 70 UCS-2 characters per segment), sanitize content if storing/displaying, and validate against prohibited content patterns.
    • Use validation libraries like joi or express-validator for structured validation.
  • Rate Limiting (Application Level): Beyond Twilio's limits, add rate limiting to your /api/send-sms endpoint using express-rate-limit:

    javascript
    const rateLimit = require('express-rate-limit');
    const limiter = rateLimit({
      windowMs: 15 * 60 * 1000, // 15 minutes
      max: 10, // Limit each IP to 10 requests per windowMs
      message: 'Too many SMS requests from this IP, try again later.'
    });
    app.post('/api/send-sms', limiter, async (req, res) => { /* ... */ });

    This prevents abuse and controls costs.

  • CORS Configuration (Production): In server.js, app.use(cors()); allows any origin. For production, restrict this to only your deployed frontend's domain:

    javascript
    // Example: Restrictive CORS for production in backend/server.js
    const allowedOrigins = ['https://your-frontend-domain.com']; // Replace with your frontend URL(s)
    const corsOptions = {
      origin: function (origin, callback) {
        // Allow requests with no origin (like mobile apps or curl requests)
        if (!origin) return callback(null, true);
        if (allowedOrigins.indexOf(origin) === -1) {
          const msg = 'The CORS policy for this site does not allow access from the specified Origin.';
          return callback(new Error(msg), false);
        }
        return callback(null, true);
      }
    };
    app.use(cors(corsOptions));
  • Authentication/Authorization: This example has no user authentication. In a real application, ensure only logged-in/authorized users can trigger SMS messages, possibly with further restrictions based on roles or permissions.

Conclusion

You've built a basic application for sending SMS messages using Node.js, Vite, React, and Twilio. Key takeaways include separating frontend and backend concerns, securely managing API credentials using environment variables, handling asynchronous API calls, and implementing basic error handling. Address security, validation, and compliance requirements (like A2P 10DLC) before deploying to production.


Frequently Asked Questions

How do I send SMS messages with Twilio and React?

Send SMS messages by creating a Node.js Express backend that handles Twilio API calls, keeping credentials secure on the server. Build a React frontend form that sends recipient numbers and messages to your backend API endpoint. Use fetch() or axios to POST data to /api/send-sms, which calls client.messages.create() with Twilio credentials stored in environment variables. Never expose Account SID or Auth Token in frontend code.

What is A2P 10DLC and do I need it for SMS campaigns?

A2P 10DLC (Application-to-Person 10-Digit Long Code) is a US carrier requirement for businesses sending SMS from standard phone numbers, effective September 1, 2022. Register your business with The Campaign Registry (TCR), register your messaging campaign, and complete carrier vetting (1-2 weeks). Unregistered traffic faces heavy filtering and delivery failures. Trial Twilio accounts are exempt but have restricted sending capabilities.

How do I validate phone numbers in E.164 format?

Validate E.164 format using the regex /^\+[1-9]\d{1,14}$/ which checks for: + prefix, country code starting with 1-9, and 1-15 total digits (including country code). For production, use the libphonenumber-js library which validates region-specific rules, checks number types (mobile vs landline), and handles international formatting. E.164 is the ITU-T international telephone numbering standard required by Twilio API.

What Twilio error codes should I handle in my SMS app?

Handle these critical Twilio error codes: 21211 (invalid 'To' number or unverified on trial), 21212 (invalid 'From' number), 20003 (authentication failed), 21408 (SMS capability not enabled), 21610 (unsubscribed recipient who texted "STOP"), 30007 (carrier-filtered content), and 21608 (unreachable number). Each error returns specific HTTP status codes – catch them in try-catch blocks and provide user-friendly messages.

How do I secure Twilio credentials in Node.js?

Secure Twilio credentials by storing Account SID and Auth Token in a .env file (never commit to Git), loading them with dotenv on the backend only, and never exposing them in frontend code or API responses. Use environment variables in production deployment platforms. Rotate your Auth Token immediately if compromised. Add rate limiting with express-rate-limit to prevent abuse and implement CORS restrictions for production environments.

Can I send SMS to international numbers with Twilio?

Yes, send international SMS by purchasing a Twilio phone number with international SMS capabilities and formatting recipient numbers in E.164 format (e.g., +44 for UK, +61 for Australia). Check Twilio's geographic permissions in your account settings and enable specific countries. Pricing varies by destination country. Some countries have additional registration requirements – consult Twilio's country-specific SMS guidelines before launching international campaigns.

What are SMS character limits and message segmentation?

SMS messages are limited to 160 GSM-7 characters or 70 UCS-2 (Unicode) characters per segment. Messages exceeding these limits split into multiple segments, each charged separately. Emojis and special characters trigger UCS-2 encoding, reducing the limit to 70 characters. Twilio automatically handles segmentation and reassembly. Use message length validation in your code to warn users and prevent unexpected costs for multi-segment messages.

How do I handle STOP/START opt-out messages with Twilio?

Twilio automatically processes STOP, UNSTOP, START, and HELP keywords sent by recipients. When someone texts STOP to your number, Twilio blocks future messages (Error 21610 if you try sending). Recipients must text START to re-subscribe. Access opt-out status via Twilio Console or API. For compliance with TCPA and carrier guidelines, always respect opt-outs, include clear opt-out instructions in initial messages, and never override Twilio's automatic handling.

Frequently Asked Questions

How to send SMS messages with Node.js and Twilio

Use the Twilio Node.js helper library in your backend API. After initializing the Twilio client with your credentials, call `client.messages.create()` with the message body, your Twilio phone number, and the recipient's number in E.164 format (+1XXXXXXXXXX). Ensure your credentials are stored securely in a .env file on the server-side, never exposed to the frontend.

What is the purpose of Vite in this project?

Vite is a modern frontend build tool used to improve the development experience. It offers faster startup times, hot module reloading, and optimized builds, making the process of building and testing the React frontend significantly quicker and more efficient.

Why use a separate backend for sending SMS?

A separate backend is essential for security. It prevents your Twilio Account SID and Auth Token from being exposed to the client-side browser. The backend handles the Twilio integration and keeps your sensitive credentials protected.

When should I register for A2P 10DLC?

Register for A2P 10DLC before sending application-to-person (A2P) SMS messages from a standard number to users in the US or Canada. It's a regulatory requirement. Trial accounts have limitations and are not subject to the same rules, but messages are marked as from a trial account.

Can I use this setup for production?

While this project provides a basic foundation, enhance it before production deployment. Strengthen input validation, implement robust error handling, and configure more restrictive CORS settings. Consider application-level rate limiting and add authentication/authorization if needed.

How to structure the project directories?

Create a main project directory (e.g., twilio-sms-app), with 'backend' and 'frontend' subdirectories. The backend will house the Node.js server (server.js, .env, .gitignore), while the frontend contains the React app (App.jsx, .env, .gitignore).

What is the role of the .env file?

The .env file stores environment variables, such as your Twilio credentials (Account SID, Auth Token, Twilio Phone Number) in the backend, and the backend API URL in the frontend. These values are loaded into the application at runtime but should never be committed to version control.

Why does my Twilio trial account only send to my verified number?

Twilio trial accounts are restricted to sending SMS only to the phone number(s) you've verified in your Twilio Console. Sending to any other numbers will fail. This is a security measure and part of the trial account limitations.

How to handle Twilio API errors?

The provided backend code includes a try...catch block to handle Twilio API errors. It logs error details to the console and returns a JSON response with 'success: false' and an appropriate error message and code to the frontend for display.

What is CORS and why is it necessary?

CORS (Cross-Origin Resource Sharing) is a security mechanism that allows or restricts web pages from one origin (domain, protocol, and port) to access resources from a different origin. It's enabled by the 'cors' middleware in the backend to allow communication between the frontend (running on a different port during development) and the backend API.

How to validate the recipient's phone number?

The frontend performs a basic E.164 check (starts with '+', followed by digits). However, this basic check allows invalid numbers. A more robust method for production is using a dedicated phone number validation library, such as `libphonenumber-js` on both frontend and backend, and stricter E.164 checks in the backend.

When testing locally, which port does the backend use?

The backend server uses port 3001 by default, as specified in server.js. You can change it if necessary, and also update the corresponding VITE_API_URL in your frontend/.env file.

What is the architecture of this application?

The application consists of a React frontend (using Vite) that sends data to a Node.js/Express backend API. The backend interacts with the Twilio API to send SMS messages. Sensitive credentials are stored in .env files and loaded via environment variables.