Developer Guide: Implementing Bulk SMS Broadcasts with Next.js and AWS SNS
A step-by-step guide for building a Next.js feature to send bulk SMS messages efficiently using AWS SNS PublishBatch API.
Code examples18 min read
Frequently asked questions
Create a Next.js API route that uses the AWS SDK for JavaScript v3 to interact with the SNS PublishBatch API. This API allows sending up to 10 messages per request, improving speed and cost-efficiency compared to individual messages.
AWS SNS PublishBatch enables sending multiple SMS messages (up to 10) in a single API call. This method reduces overhead and improves performance, especially for bulk messaging, compared to individual API calls for each message.
AWS SDK v3 offers benefits like modular packages for reduced bundle size, improved TypeScript support, and better alignment with modern JavaScript practices, making it a more efficient choice than v2 for interacting with AWS services like SNS.
IAM roles are strongly recommended for production deployments on AWS services like EC2, ECS, or Lambda, or platforms supporting IAM role integration, like Vercel. They provide secure, temporary credentials without the risks associated with long-lived access keys stored in environment variables.
Yes, you can send SMS messages directly to phone numbers using the PublishBatch API by specifying the PhoneNumber property for entries. This is suitable for broadcast scenarios where recipients are not subscribed to a specific SNS topic.
Store AWS credentials (access key ID and secret access key) in a .env.local file for local development, which should be included in your .gitignore to avoid committing secrets. For production, utilize IAM roles instead of storing access keys directly in your application's environment.
The maximum batch size for AWS SNS PublishBatch is 10 messages per API request. The provided implementation chunks larger lists into batches of this size to efficiently process bulk messages.
Implement retry logic with exponential backoff for failed messages within a batch. Differentiate between retryable errors (throttling) and non-retryable errors (invalid numbers) to avoid unnecessary retries and enhance reliability. Log failures for diagnostics.
Message attributes in SNS enable you to add metadata to your SMS messages, like specifying 'Transactional' or 'Promotional' message types, setting Sender ID, and including custom tags. This offers fine-grained control over message properties and behaviors.
Replace placeholder API key authentication with robust methods like JWT, OAuth, or session-based authentication. Implement input validation, rate limiting, and proper authorization to protect against unauthorized access and abuse.
Use the E.164 format (+[country code][number]) for phone numbers, such as +14155552671. This ensures consistent and reliable delivery of SMS messages across different regions.
SNS automatically handles standard opt-out keywords (like STOP). Your application should gracefully handle and log these failures, preventing future attempts to opted-out numbers, and potentially update internal user statuses.
E.164 format ensures consistent and reliable SMS delivery globally. It includes the country code, facilitating international messaging and preventing ambiguity in number interpretation by SNS and carriers.
SNS automatically segments long messages exceeding the standard SMS length (160 characters for GSM-7, 70 for UCS-2) into multiple segments, billed individually. Be aware of encoding and length for cost management.
One API. Every channel.
Contact Sales
This guide provides a step-by-step walkthrough for building a feature within a Next.js application to send bulk SMS messages (broadcasts) efficiently using Amazon Simple Notification Service (SNS) and its
PublishBatchAPI.Sending individual messages via API calls can become slow and costly when dealing with hundreds or thousands of recipients. AWS SNS
PublishBatchenables you to send up to 10 messages in a single API request, drastically reducing overhead and potentially lowering costs. We will build a secure Next.js API endpoint that accepts a list of phone numbers and a message, then uses the AWS SDK for JavaScript (v3) to interact with SNS for batch delivery.Project Goals:
PublishBatchCommand.Technology Stack:
System Architecture:
Prerequisites:
v18or later recommended) and npm/yarn installed.1. Project Setup
Let's initialize a new Next.js project and install the necessary dependencies.
Create Next.js App: Open your terminal and run:
npx create-next-app@latest sns-bulk-messaging-app cd sns-bulk-messaging-app(Choose your preferred settings regarding TypeScript, ESLint, Tailwind,
src/directory, App Router/Pages Router – this guide assumes Pages Router for API route simplicity, but App Router works similarly).Install AWS SDK: We need the SNS client package from the AWS SDK v3.
Environment Variables: Sensitive information like AWS credentials and the SNS Topic ARN (if used) should never be hardcoded. Create a file named
.env.localin the root of your project.AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY: Credentials for an IAM user with programmatic access. Strongly recommended: For production, use IAM roles attached to your compute environment (like Vercel, EC2, Lambda) instead of long-lived keys. See Section 6 for more on security.AWS_REGION: The AWS region where your SNS resources will be or are located (e.g.,us-west-2,eu-central-1).SNS_TOPIC_ARN: (Optional) If you intend to publish to a topic where users subscribe, include its ARN. This guide focuses on direct-to-phone-number batching, which doesn't strictly require a topic ARN for thePublishBatchcommand itself when specifying phone numbers directly in the entries.Gitignore: Ensure
.env.localis included in your.gitignorefile to prevent accidentally committing secrets. The default Next.js.gitignoreusually includes it.2. AWS SNS and IAM Configuration
Before writing code, we need to configure AWS resources.
IAM User/Role and Permissions: You need an IAM identity (user or role) that your Next.js application can use to interact with SNS.
YOUR_REGION,YOUR_ACCOUNT_ID, and optionally specify a Topic ARN if you only want to allow publishing to a specific topic. For direct SMS batching, theResource: ""*""might be necessary if not tied to a specific topic. Consult AWS documentation for the most precise permissions needed for direct SMS batch publish without a topic. For simplicity here, we grant broad publish access.{ ""Version"": ""2012-10-17"", ""Statement"": [ { ""Effect"": ""Allow"", ""Action"": [ ""sns:Publish"", ""sns:GetSMSAttributes"", ""sns:SetSMSAttributes"" ], ""Resource"": ""*"" } ] }NextJsSnsBroadcastPolicy), and create it.nextjs-sns-app-user).NextJsSnsBroadcastPolicyyou just created..env.localfor local dev, or a secrets manager). This is the only time the secret key is shown.NextJsSnsBroadcastPolicyto the role, and configure your deployment environment to use this role. This avoids storing long-lived access keys in your application environment.SNS Configuration (Optional but Recommended):
3. Implementing Core Batch Messaging Logic
Let's create the core function responsible for batching and sending messages.
Create SNS Client Utility: Create a file
lib/snsClient.jsto initialize and export the SNS client instance.// lib/snsClient.js import { SNSClient } from "@aws-sdk/client-sns"; // Ensure environment variables are loaded (Next.js does this automatically for .env.local) const region = process.env.AWS_REGION; const accessKeyId = process.env.AWS_ACCESS_KEY_ID; const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; if (!region) { // Region is always required console.error("AWS_REGION environment variable is not set."); // Potentially throw an error or handle appropriately depending on deployment strategy } if (!accessKeyId || !secretAccessKey) { // In production, prefer IAM Roles, so keys might be undefined. // The SDK will automatically attempt to find credentials in the environment (e.g., instance profile). console.warn("AWS Access Key ID or Secret Access Key might be missing. Ensure IAM Role is configured for production or keys are set for local dev."); } const snsClientConfig = { region: region, // Only provide credentials if they are explicitly set in the environment. // If running with an IAM Role (e.g., on EC2/Lambda/Vercel), omit credentials // and the SDK will automatically use the role's permissions. ...(accessKeyId && secretAccessKey && { credentials: { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey, }, }), }; const snsClient = new SNSClient(snsClientConfig); export { snsClient };Create Batch Publishing Helper: Create
lib/publishBatchHelper.js. This function will take phone numbers and a message, chunk them, and send batches to SNS.// lib/publishBatchHelper.js import { PublishBatchCommand } from "@aws-sdk/client-sns"; import { snsClient } from "./snsClient"; // Import the initialized client const MAX_BATCH_SIZE = 10; // SNS PublishBatch limit const MAX_RETRIES = 3; // Max retries for failed messages within a batch const INITIAL_DELAY_MS = 200; // Initial delay for exponential backoff /** * Sends messages in batches using SNS PublishBatch. * Handles basic retries for failed messages within a batch using exponential backoff. * NOTE: This basic retry attempts all failures; production logic should differentiate * between retryable (e.g., throttling) and non-retryable (e.g., invalid number) errors. * * @param {string[]} phoneNumbers - Array of E.164 formatted phone numbers. * @param {string} message - The message content to send. * @returns {Promise<{success: {id: string, phoneNumber: string, messageId: string}[], failed: {id: string, phoneNumber: string, code: string, message: string}[]}>} - Summary of successful and failed sends. */ export const sendBulkSms = async (phoneNumbers, message) => { const allSuccessful = []; const allFailed = []; const uniqueIdPrefix = `batch-${Date.now()}`; // Unique prefix for batch entries // Chunk phone numbers into batches of MAX_BATCH_SIZE for (let i = 0; i < phoneNumbers.length; i += MAX_BATCH_SIZE) { const batchNumbers = phoneNumbers.slice(i, i + MAX_BATCH_SIZE); let currentBatchEntries = batchNumbers.map((num, index) => ({ Id: `${uniqueIdPrefix}-${i + index}`, // Unique ID within the entire operation PhoneNumber: num, // Use PhoneNumber for direct SMS Message: message, // Optional: Add MessageAttributes for SenderID, SMSType etc. // MessageAttributes: { // 'AWS.SNS.SMS.SMSType': { DataType: 'String', StringValue: 'Transactional' }, // 'AWS.SNS.SMS.SenderID': { DataType: 'String', StringValue: 'MySenderID' } // } })); let attempts = 0; let batchFailed = true; // Assume failure until proven otherwise let failedEntriesDetails = new Map(); // Store details of failures for final reporting while (attempts < MAX_RETRIES && currentBatchEntries.length > 0) { failedEntriesDetails.clear(); // Clear details for this attempt try { const command = new PublishBatchCommand({ // TopicArn: process.env.SNS_TOPIC_ARN, // Use this if publishing to a Topic instead PublishBatchRequestEntries: currentBatchEntries, }); console.log(`Attempt ${attempts + 1}: Sending batch of ${currentBatchEntries.length} messages starting with ID ${currentBatchEntries[0].Id}...`); const response = await snsClient.send(command); console.log(`Batch Response Received. Success: ${response.Successful?.length || 0}, Failed: ${response.Failed?.length || 0}`); // Process successful messages if (response.Successful) { response.Successful.forEach(success => { // Find the original entry to get the phone number const originalEntryIndex = currentBatchEntries.findIndex(entry => entry.Id === success.Id); if (originalEntryIndex !== -1) { allSuccessful.push({ id: success.Id, phoneNumber: currentBatchEntries[originalEntryIndex].PhoneNumber, messageId: success.MessageId, }); // Remove successful entry from current batch for retry logic currentBatchEntries.splice(originalEntryIndex, 1); } }); } // Identify entries that failed in this attempt and store details if (response.Failed) { response.Failed.forEach(failure => { // Only retry sender faults potentially? Or specific error codes? Be selective. // The current simplistic logic retries all failures. failedEntriesDetails.set(failure.Id, failure); console.error(`Failed Entry: ID=${failure.Id}, Code=${failure.Code}, Message=${failure.Message}, SenderFault=${failure.SenderFault}`); // Keep the failed entry in currentBatchEntries for the next retry attempt }); } // Check if any entries are left in the current batch to retry if (currentBatchEntries.length === 0) { batchFailed = false; // All entries in the original batch succeeded eventually break; // Exit retry loop for this batch } else { // Apply exponential backoff before retrying remaining entries const delay = INITIAL_DELAY_MS * Math.pow(2, attempts); console.log(`Retrying ${currentBatchEntries.length} failed entries after ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); // currentBatchEntries already contains only the failures to retry } } catch (error) { console.error(`Error sending batch (Attempt ${attempts + 1}):`, error); // If the entire batch request fails (e.g., network error, throttling), retry all entries in the current batch. const delay = INITIAL_DELAY_MS * Math.pow(2, attempts); console.log(`Retrying entire batch after ${delay}ms due to error...`); await new Promise(resolve => setTimeout(resolve, delay)); // Keep currentBatchEntries as is for the next attempt } attempts++; } // End while retry loop // After retries, any remaining entries in currentBatchEntries are considered finally failed if (batchFailed && currentBatchEntries.length > 0) { console.error(`Batch finally failed after ${MAX_RETRIES} attempts for IDs: ${currentBatchEntries.map(e => e.Id).join(', ')}`); currentBatchEntries.forEach(entry => { // Use the last known failure details or create a generic one const failureDetails = failedEntriesDetails.get(entry.Id) || { Code: 'RetryFailed', Message: `Failed after ${MAX_RETRIES} attempts`, SenderFault: true }; allFailed.push({ id: entry.Id, phoneNumber: entry.PhoneNumber, // We still have the full entry object here code: failureDetails.Code, message: failureDetails.Message, }); }); } } // End for loop iterating through chunks console.log(`Bulk SMS process completed. Success: ${allSuccessful.length}, Failed: ${allFailed.length}`); return { success: allSuccessful, failed: allFailed }; };PhoneNumber: It uses thePhoneNumberproperty withinPublishBatchRequestEntriesfor direct SMS, which is common for broadcast use cases where recipients aren't necessarily subscribed to a topic.Id) for each message within the batch, essential for correlating responses in thePublishBatchResult.SuccessfulandFailedarrays from the SNS response to track individual message status.InvalidParameterValuefor a bad phone number orOptedOut) to avoid unnecessary retries.console.logandconsole.errorfor visibility (replace with a proper logger in production).4. Building the API Layer
Now, let's create the Next.js API route that will expose this functionality.
Create API Route File: Create
pages/api/broadcast.js.// pages/api/broadcast.js import { sendBulkSms } from '../../lib/publishBatchHelper'; // Adjust path if using src/ directory // Optional: Use a validation library like zod for robust input validation // import { z } from 'zod'; // WARNING: Basic API Key Auth - Placeholder Only! // Replace this with a robust authentication mechanism (JWT, OAuth, etc.) in production. const DUMMY_API_KEY = process.env.BROADCAST_API_KEY || 'verysecretkey'; // Load from env // Optional: Zod schema for validation // const broadcastSchema = z.object({ // phoneNumbers: z.array(z.string().regex(/^\+?[1-9]\d{1,14}$/)).min(1), // E.164 format regex (basic) // message: z.string().min(1).max(1600), // SMS max length varies, 1600 is generous // }); export default async function handler(req, res) { // 1. Authentication & Authorization (PLACEHOLDER - REPLACE THIS) const apiKey = req.headers['x-api-key']; if (req.method !== 'POST') { res.setHeader('Allow', ['POST']); return res.status(405).end(`Method ${req.method} Not Allowed`); } // **THIS IS NOT PRODUCTION-READY AUTHENTICATION.** if (!apiKey || apiKey !== DUMMY_API_KEY) { console.warn('Unauthorized attempt to access broadcast API.'); return res.status(401).json({ error: 'Unauthorized: Invalid or missing API key.' }); } // In production, replace the above check with proper session, token, or service-to-service auth. // 2. Request Validation const { phoneNumbers, message } = req.body; // Basic validation (replace/enhance with Zod or similar) if (!Array.isArray(phoneNumbers) || phoneNumbers.length === 0 || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ error: 'Invalid input: ""phoneNumbers"" must be a non-empty array and ""message"" must be a non-empty string.' }); } // Add more specific validation (e.g., E.164 format check) const invalidNumbers = phoneNumbers.filter(num => !/^\+?[1-9]\d{1,14}$/.test(num)); if (invalidNumbers.length > 0) { return res.status(400).json({ error: `Invalid phone number format for: ${invalidNumbers.join(', ')}. Use E.164 format (e.g., +1xxxxxxxxxx).` }); } // Optional: Use Zod for stricter validation // try { // broadcastSchema.parse(req.body); // } catch (error) { // return res.status(400).json({ error: 'Invalid input', details: error.errors }); // } // 3. Call Core Logic try { console.log(`Received broadcast request for ${phoneNumbers.length} numbers.`); const result = await sendBulkSms(phoneNumbers, message); console.log(`Broadcast API Result: Success=${result.success.length}, Failed=${result.failed.length}`); // 4. Respond // Depending on needs, you might only return a summary or include full details return res.status(200).json({ message: `Broadcast attempt finished.`, attempted: phoneNumbers.length, sentSuccessfully: result.success.length, failedToSend: result.failed.length, // Optionally include details (can be large): // successes: result.success, failures: result.failed, // Good to return failures for diagnostics }); } catch (error) { console.error('Error processing broadcast request in API handler:', error); return res.status(500).json({ error: 'Internal Server Error processing broadcast request.' }); } }x-api-keyheader. This is NOT production-ready and MUST be replaced. Use proper authentication (e.g., JWT, OAuth, session cookies, dedicated auth providers) based on your application's security requirements. Load the placeholder key from environment variables (BROADCAST_API_KEY).phoneNumbersarray,messagestring). Includes a commented-out example usingzodfor more robust validation, including a basic E.164 regex check. Remember tonpm install zodif you use it.try...catchto handle errors during thesendBulkSmscall and returns appropriate HTTP status codes (400, 401, 405, 500).Testing the API Endpoint: You can use
curlor a tool like Postman/Insomnia.Set API Key Env Var: Before running, make sure you add
BROADCAST_API_KEY=verysecretkey(or your chosen key) to your.env.localfile. Restart your Next.js dev server (npm run dev) after adding it.curlExample: Replace+15551234567,+15557654321with valid test phone numbers (use your own mobile number for initial testing).curl -X POST http://localhost:3000/api/broadcast \ -H ""Content-Type: application/json"" \ -H ""x-api-key: verysecretkey"" \ -d '{ ""phoneNumbers"": [""+15551234567"", ""+15557654321""], ""message"": ""Hello from our Next.js broadcast system! (Test)"" }'Expected Response (Success Scenario):
{ ""message"": ""Broadcast attempt finished."", ""attempted"": 2, ""sentSuccessfully"": 2, ""failedToSend"": 0, ""failures"": [] }Expected Response (Partial Failure Scenario):
{ ""message"": ""Broadcast attempt finished."", ""attempted"": 2, ""sentSuccessfully"": 1, ""failedToSend"": 1, ""failures"": [ { ""id"": ""batch-1678886400000-1"", ""phoneNumber"": ""+15557654321"", ""code"": ""InvalidParameter"", ""message"": ""Invalid parameter: PhoneNumber"" } ] }5. Implementing Proper Error Handling, Logging, and Retry Mechanisms
try...catchblocks in API handlers and helper functions.4xxfor client errors,5xxfor server errors).console.logfor informational messages (request received, batch sent) andconsole.errorfor errors.// Replace console.log/error with logger calls // import pino from 'pino'; // const logger = pino(); // logger.info({ batchId: command.PublishBatchRequestEntries[0].Id, count: command.PublishBatchRequestEntries.length }, 'Sending SNS batch'); // logger.error({ err: error, batchId: ... }, 'Error sending SNS batch');publishBatchHelperincludes exponential backoff for failed messages within a batch attempt.snsClient.senditself throws an error (e.g., network issue, throttling).InvalidParameterValue(bad number) orOptedOut. Only retry potentially transient errors likeThrottlingException,InternalFailure, orServiceUnavailable. This requires inspecting thefailure.Codeandfailure.SenderFaultproperties from theresponse.Failedarray.snsClient.sendmethod in unit tests to throw specific errors.6. Adding Security Features
sns:Publish.zod(Section 4) to strictly validatephoneNumbers(E.164 format) andmessagecontent/length./api/broadcast) from abuse. Implement rate limiting based on IP address, API key, or user ID. Libraries likerate-limiter-flexibleor Vercel's built-in features can help.PublishBatchhelps stay within these, but be aware of them..env.localis gitignored.7. Handling Special Cases Relevant to the Domain (SMS)
+followed by country code and number, e.g.,+14155552671). The validation in Section 4 includes a basic regex.OptedOut. Your application should gracefully handle these failures reported byPublishBatch(or singlePublish), log them, and potentially update the user's status in your own database to prevent future attempts. Do not retry messages that failed due to opt-out.AWS.SNS.SMS.SenderIDmessage attribute inPublishBatchRequestEntriesif needed.AWS.SNS.SMS.SMSTypemessage attribute ('Transactional' or 'Promotional') if you need to override the account default per message. Ensure compliance with regulations for promotional messages (e.g., opt-in requirements, sending hours).8. Implementing Performance Optimizations
PublishBatch): This is the primary performance optimization for this use case, reducing API calls by up to 10x compared to individualPublishcalls. The core logic (Section 3) already implements this.