This guide provides a step-by-step walkthrough for building a full-stack application using RedwoodJS that enables you to manage marketing campaigns and send SMS messages to subscribers via AWS Simple Notification Service (SNS).
We will build a simple web interface to manage lists of subscribers (names and phone numbers) and campaigns (names and message templates). You'll be able to trigger a campaign, which sends the specified message to all subscribed phone numbers using AWS SNS. This provides a foundation for a scalable SMS marketing platform.
Technologies Used:
RedwoodJS: A full-stack, serverless-friendly JavaScript/TypeScript framework built on React, GraphQL, and Prisma. Chosen for its integrated structure, developer experience, and ease of deployment.
AWS SNS: A managed messaging service for sending messages (SMS, email, push notifications) at scale. Chosen for its reliability, scalability, and direct integration capabilities.
AWS SDK for JavaScript v3: Used within the RedwoodJS API side to interact with the AWS SNS service.
Prisma: A next-generation ORM for Node.js and TypeScript, Redwood's default data access layer.
GraphQL: The query language for your API, facilitated by Redwood's structure.
React: For building the frontend user interface.
Serverless Framework: Used by Redwood's setup command for deploying to AWS Lambda and S3/CloudFront (Note: See deployment section regarding deprecation status).
Yarn (Classic v1 or Berry v3+, check RedwoodJS docs for current recommendations)
An AWS Account
AWS CLI installed and configured (aws configure)
By the end of this guide, you will have a deployed RedwoodJS application capable of managing subscribers and sending SMS campaigns via AWS SNS.
1. Project Setup and Configuration
Let's initialize our RedwoodJS project and set up the necessary configurations for AWS deployment.
Create RedwoodJS App:
Open your terminal and run:
Bash
yarn create redwood-app redwood-sns-campaigns
cd redwood-sns-campaigns
Follow the prompts (choosing TypeScript is recommended).
Configure AWS CLI:
If you haven't already, configure your AWS CLI with credentials that have permissions for SNS, Lambda, S3, CloudFront, and API Gateway. Running aws configure will prompt you for your Access Key ID, Secret Access Key, default region, and output format.
Bash
aws configure
# Follow prompts:# AWS Access Key ID [None]: YOUR_ACCESS_KEY_ID# AWS Secret Access Key [None]: YOUR_SECRET_ACCESS_KEY# Default region name [None]: us-east-1 # Or your preferred region# Default output format [None]: json
Why this region? Choose a region physically close to you or your users, and ensure SNS SMS support is available there. us-east-1 is a common default.
Setup Serverless Deployment:
RedwoodJS provides a setup command to configure deployment using the Serverless Framework for AWS.
Bash
yarn rw setup deploy serverless
This command adds serverless.yml configuration files to your api and web directories and installs necessary dependencies.
Note on Deprecation: As mentioned in the RedwoodJS v5+ documentation, this Serverless Framework deployment method is deprecated. While it still functions as of this writing, RedwoodJS is exploring alternative AWS deployment options. For production systems requiring long-term support, monitor RedwoodJS announcements for updated deployment strategies.
Configure AWS Credentials in .env:
The Serverless deployment needs your AWS credentials. Add them to your .env file at the project root. Never commit your .env file to version control. Redwood adds .env to .gitignore by default.
Text
# .env
# Add these lines:
AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID_HERE
AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY_HERE
AWS_REGION=us-east-1 # Use the same region as configured in AWS CLI
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY: Obtain these from AWS IAM when creating a user with programmatic access. Security Best Practice: Grant this user the least privilege necessary. While broader permissions like AmazonS3FullAccess, CloudFrontFullAccess, AWSLambda_FullAccess, AmazonAPIGatewayAdministrator, and AmazonSNSFullAccess might be simpler for initial setup, they are less secure. For this specific application's core function, the minimum required permission for sending messages is sns:Publish. You will need broader permissions for the deployment itself (handled by Serverless Framework using these credentials or potentially an IAM role), but the running Lambda function ideally only needs sns:Publish (see Section 6). Start with broader permissions if needed during setup, but plan to restrict them significantly for production.
AWS_REGION: The AWS region where your services (Lambda, SNS) will operate. Must match the region used during aws configure.
2. Database Schema and Data Layer
We'll use Prisma to define our database models for Campaigns and Subscribers.
Define Prisma Schema:
Open api/db/schema.prisma and define the models:
Prisma
// api/db/schema.prisma
datasource db {
provider = ""postgresql"" // Or ""sqlite"", ""mysql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native""
}
model Campaign {
id Int @id @default(autoincrement())
name String @unique
message String
createdAt DateTime @default(now())
}
model Subscriber {
id Int @id @default(autoincrement())
name String?
phoneNumber String @unique // Ensure phone numbers are unique
subscribed Boolean @default(true)
createdAt DateTime @default(now())
}
Why these models?Campaign stores the message content, and Subscriber stores contact information. phoneNumber is unique to avoid duplicates. subscribed allows for opt-outs later.
Set Database URL:
Ensure your DATABASE_URL in .env points to your development database (e.g., postgresql://user:password@localhost:5432/mydb). For SQLite (default), it's usually file:./dev.db.
Run Migrations:
Apply the schema changes to your database and generate the Prisma client.
Bash
yarn rw prisma migrate dev
Enter a name for the migration when prompted (e.g., create_campaigns_subscribers).
3. API Layer (GraphQL)
Now, let's build the GraphQL API endpoints to manage campaigns and subscribers, including the logic to send campaigns.
Generate GraphQL Scaffolding:
Use Redwood's generators to create SDL (Schema Definition Language) files and services.
Bash
yarn rw g sdl Campaign --crud
yarn rw g sdl Subscriber --crud
This creates basic CRUD (Create, Read, Update, Delete) operations for both models.
Implement Campaign Sending Logic:
We need a mutation to trigger sending a specific campaign to all active subscribers.
Install AWS SDK for SNS: Add the SNS client package to your API workspace.
Bash
yarn workspace api add @aws-sdk/client-sns
Define the Mutation SDL: Add the sendCampaign mutation to api/src/graphql/campaigns.sdl.ts.
GraphQL
# api/src/graphql/campaigns.sdl.ts# Note: Redwood uses gql tagged template literals in TS files,# but the content itself is GraphQL SDL.type Campaign {id: Int!name: String!message: String!createdAt: DateTime!}typeQuery{campaigns:[Campaign!]!@requireAuth
campaign(id: Int!): Campaign @requireAuth}input CreateCampaignInput {name: String!message: String!}input UpdateCampaignInput {name: String
message: String
}# Result type for the send operationtype SendCampaignResult {successCount: Int!failureCount: Int!message: String!failedNumbers:[String!]}typeMutation{
createCampaign(input: CreateCampaignInput!): Campaign!@requireAuth
updateCampaign(id: Int!, input: UpdateCampaignInput!): Campaign!@requireAuth
deleteCampaign(id: Int!): Campaign!@requireAuth# Add this mutation
sendCampaign(id: Int!): SendCampaignResult!@requireAuth(roles:[""admin""])# Example: Restrict access}
Why SendCampaignResult? It provides feedback to the user about the outcome of the send operation.
Why @requireAuth(roles: [""admin""])? Sending campaigns often has costs and impact, so restricting it to specific user roles (once authentication is added) is crucial. We'll use basic @requireAuth for now.
Implement the Service Logic: Add the sendCampaign function to api/src/services/campaigns/campaigns.ts.
TypeScript
// api/src/services/campaigns/campaigns.tsimport { SNSClient, PublishCommand } from'@aws-sdk/client-sns'importtype {
QueryResolvers,
MutationResolvers,
CampaignResolvers,
} from'types/graphql'import { db } from'src/lib/db'import { logger } from'src/lib/logger'// Import Redwood loggerimport { requireAuth } from'src/lib/auth'// Ensure user is logged in// Initialize SNS Client - Reads region and credentials from env vars automatically// Ensure AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY are in .envconst snsClient = newSNSClient({})
exportconstcampaigns: QueryResolvers['campaigns'] = () => {
return db.campaign.findMany()
}
exportconstcampaign: QueryResolvers['campaign'] = ({ id }) => {
return db.campaign.findUnique({
where: { id },
})
}
exportconstcreateCampaign: MutationResolvers['createCampaign'] = ({ input }) => {
return db.campaign.create({
data: input,
})
}
exportconstupdateCampaign: MutationResolvers['updateCampaign'] = ({ id, input }) => {
return db.campaign.update({
data: input,
where: { id },
})
}
exportconstdeleteCampaign: MutationResolvers['deleteCampaign'] = ({ id }) => {
return db.campaign.delete({
where: { id },
})
}
// Add this functionexportconstsendCampaign: MutationResolvers['sendCampaign'] = async ({ id }) => {
requireAuth({ roles: ['admin'] }) // Or adjust roles as neededconst campaignToSend = await db.campaign.findUnique({ where: { id } })
if (!campaignToSend) {
thrownewError(`Campaign with id ${id} not found.`)
}
const subscribers = await db.subscriber.findMany({
where: { subscribed: true }, // Only send to active subscribers
})
if (subscribers.length === 0) {
return { successCount: 0, failureCount: 0, message: 'No active subscribers found.', failedNumbers: [] }
}
let successCount = 0let failureCount = 0constfailedNumbers: string[] = []
logger.info(`Starting campaign send for campaign ID: ${id} to ${subscribers.length} subscribers.`)
for (const subscriber of subscribers) {
// Basic validation - SNS requires E.164 format (e.g., +12223334444)if (!subscriber.phoneNumber || !/^\+\d{1,15}$/.test(subscriber.phoneNumber)) {
logger.warn(`Skipping invalid phone number: ${subscriber.phoneNumber} for subscriber ID: ${subscriber.id}`)
failureCount++
failedNumbers.push(subscriber.phoneNumber || 'MISSING_NUMBER')
continue;
}
const params = {
Message: campaignToSend.message,
PhoneNumber: subscriber.phoneNumber,
// Optional: Add MessageAttributes for SenderID, etc.// MessageAttributes: {// 'AWS.SNS.SMS.SenderID': {// 'DataType': 'String',// 'StringValue': 'MyBrand' // Requires registration in some regions// },// 'AWS.SNS.SMS.SMSType': {// 'DataType': 'String',// 'StringValue': 'Transactional' // Or 'Promotional'// }// }
}
try {
const command = newPublishCommand(params)
await snsClient.send(command)
logger.debug(`Successfully sent SMS to ${subscriber.phoneNumber} for campaign ID: ${id}`)
successCount++
} catch (error) {
logger.error(`Failed to send SMS to ${subscriber.phoneNumber}: ${error.message}`, error)
failureCount++
failedNumbers.push(subscriber.phoneNumber)
}
}
logger.info(`Finished campaign send for campaign ID: ${id}. Success: ${successCount}, Failures: ${failureCount}`)
return {
successCount,
failureCount,
message: `Campaign send complete. Success: ${successCount}, Failures: ${failureCount}.`,
failedNumbers,
}
}
exportconstCampaign: CampaignResolvers = {
// If you need resolvers for related fields, add them here
}
Why loop and send individually? While SNS supports batch publishing to topics, sending individually to phone numbers gives granular control and error tracking per subscriber. For very large lists, consider SNS topics or batch APIs for efficiency.
Why E.164 format? AWS SNS requires phone numbers in the international E.164 format (e.g., +15551234567) for reliable delivery. The code includes a basic regex check.
Why Logging? Detailed logging helps diagnose issues during sending (e.g., invalid numbers, permission errors, SNS throttling).
4. Frontend Implementation (React)
Let's build a simple UI to interact with our API.
Generate Pages and Components:
Bash
yarn rw g page HomePage /
yarn rw g cell CampaignsCell
yarn rw g cell SubscribersCell
yarn rw g component CampaignForm
yarn rw g component SubscriberForm
Implement CampaignsCell: (web/src/components/CampaignsCell/CampaignsCell.tsx)
Fetch and display campaigns, add a form, and include a button to trigger the sendCampaign mutation.
Why useMutation? This Redwood hook simplifies calling GraphQL mutations and handling loading/error/completed states.
Why toast? Provides user feedback without disrupting the flow. Run yarn rw setup ui <your-preferred-library> (e.g., chakra-ui) if you haven't already, or use browser alert().
Implement SubscribersCell: (web/src/components/SubscribersCell/SubscribersCell.tsx)
Similar structure to CampaignsCell, displaying subscribers and including SubscriberForm.
Implement Forms: (CampaignForm.tsx, SubscriberForm.tsx)
Use Redwood's form helpers (@redwoodjs/forms) to create forms that call the respective createCampaign and createSubscriber mutations. Remember to include validation (e.g., required fields, phone number format). Refer to RedwoodJS Forms documentation for detailed implementation. Ensure the phone number input encourages or enforces the E.164 format (e.g., using a placeholder +15551234567).
Update HomePage: (web/src/pages/HomePage/HomePage.tsx)
Include the cells on the main page.
Let's deploy the application using the configured Serverless setup.
First Deployment:
The initial deployment requires a special flag to set up both API and Web sides correctly.
Bash
yarn rw deploy serverless --first-run
During this process, the API side is deployed first.
The command will then detect the deployed API's URL.
Crucially: You will be prompted: Add API_URL=<your-api-url> to .env.production? (Y/n). Type Y and press Enter. This saves the API endpoint URL so the web frontend knows where to send GraphQL requests in the deployed environment.
The command will then proceed to build and deploy the web side to S3/CloudFront.
Configure Production Environment Variables:
After the first deploy, a .env.production file is created. This file is critical for the deployed application and should NOT be committed to Git. You MUST add necessary environment variables here, as .env is not used in production deployment.
Text
# .env.production
# Added by --first-run:
API_URL=https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/ # Example URL
# *** YOU MUST ADD THESE MANUALLY ***
DATABASE_URL=postgresql://prod_user:prod_password@your_prod_db_host:5432/prod_db # Your PRODUCTION database URL
AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID_HERE # Key needed by the running Lambda
AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY_HERE # Secret needed by the running Lambda
AWS_REGION=us-east-1 # Region for the running Lambda
# Add session secrets if using Redwood Auth
# SESSION_SECRET=your_strong_random_secret_here
Why manually add? The deploy command only adds API_URL. Production database credentials and AWS keys/region needed by the running Lambda function (specifically, for the AWS SDK to interact with SNS at runtime) must be explicitly added here. These variables are packaged during the build/deploy process and made available to the Lambda environment. The credentials used by the Serverless Framework during deployment might be different (e.g., from your local aws configure or CI/CD environment) or could be the same, but the Lambda needs its own runtime access defined here.
Apply Production Migrations:
Connect to your production database and apply migrations. How you do this depends on your database hosting. You might SSH into a bastion host or use a managed database service's console. The command itself is:
Bash
# Run this command in an environment connected to your PRODUCTION database
yarn rw prisma migrate deploy
Subsequent Deploys:
For future updates after making code changes:
Bash
yarn rw deploy serverless
This command rebuilds and redeploys both the API and Web sides using the settings in serverless.yml and variables from .env.production.
Removing Your Deploy:
To tear down the deployed AWS resources and avoid costs, run the serverless remove command in both the api and web directories.
Bash
cd api
yarn serverless remove --stage production # 'production' is the default stagecd ../web
yarn serverless remove --stage production
cd ..
6. Security Considerations
Authentication: This guide omits authentication for simplicity. Use RedwoodJS Auth (yarn rw setup auth <provider>) to protect your API and UI. Restrict the sendCampaign mutation to specific roles (e.g., admin) as shown in the code example.
Input Validation: Sanitize all inputs. Use Redwood's built-in validators or libraries like zod for robust validation on both frontend forms and backend services (e.g., ensuring message content is safe, validating phone number formats strictly).
AWS Credentials: Never hardcode credentials. Use .env and .env.production correctly. Grant the IAM user the least privilege necessary (e.g., only sns:Publish if that's all the running Lambda needs). Consider using IAM Roles for Lambda functions if deploying via methods other than Serverless Framework's default credential handling, as roles are generally more secure than long-lived access keys.
Rate Limiting: Implement rate limiting on the sendCampaign mutation (and potentially others) to prevent abuse. This can be done within the service logic or using API Gateway features.
SNS Costs: Be aware of SNS SMS pricing. Implement checks or limits to prevent accidental large sends. Consider SNS sandbox limits initially.
7. Error Handling and Logging
Service Logic: Use try...catch blocks around critical operations like database calls and AWS SDK calls (snsClient.send).
Redwood Logger: Utilize Redwood's built-in logger (import { logger } from 'src/lib/logger') for structured logging within your API services. Log informative messages for successful operations and detailed error messages/stack traces for failures. Levels (debug, info, warn, error) help filter logs later.
API Responses: Provide meaningful error messages back through GraphQL, but avoid leaking sensitive details.
CloudWatch: After deployment, Lambda function logs (including output from Redwood's logger) and SNS delivery status logs are available in AWS CloudWatch. Use these for debugging production issues.
8. Testing
Unit/Integration Tests: Write tests for your services, especially the sendCampaign logic. Mock the Prisma client (@redwoodjs/testing/api) and the AWS SNS client (jest.mock('@aws-sdk/client-sns')) to test the logic without actual DB calls or sending SMS messages.
Bash
yarn rw test api
Scenario Tests: Test edge cases like invalid phone numbers, non-existent campaigns, or empty subscriber lists.
E2E Tests: Consider tools like Cypress (which Redwood can set up: yarn rw setup testing cypress) to test the full user flow from the UI.
9. Monitoring and Observability
AWS CloudWatch:
Logs: Monitor Lambda execution logs for errors and output from logger. Monitor SNS delivery status logs.
Alarms: Set CloudWatch Alarms on key metrics (e.g., high Lambda error rate, high SNS failure rate) to get notified of problems.
Redwood Logger: Ensure sufficient logging is implemented (as described in Error Handling).
10. Troubleshooting and Caveats
Serverless Deprecation: As stated, yarn rw setup deploy serverless is deprecated. Monitor RedwoodJS for future AWS deployment recommendations. The current setup might break in future Redwood versions without community maintenance.
Deployment Errors:
Error: No auth.zip file found...: Often means the dev server (yarn rw dev) is running. Stop it and retry deployment.
IAM Permissions: Ensure the AWS credentials used have all necessary permissions (Lambda, API Gateway, S3, CloudFront, SNS). Check CloudFormation stack events in the AWS console for detailed errors during deployment.
serverless.yml: If you add custom functions outside the standard GraphQL endpoint, you might need to manually add them to api/serverless.yml.
Runtime Errors (Lambda):
Missing Env Vars: Double-check that DATABASE_URL, AWS_REGION, and AWS credentials are correctly set in .env.production and redeploy if changed.
Database Connectivity: Ensure the Lambda function can reach your production database (check security groups, VPC settings, connection strings).
SNS Errors: Check CloudWatch logs. Common issues include invalid phone number format (must be E.164), hitting SNS spending limits (check SNS console), or lack of sns:Publish permission.
SNS Sandbox: New AWS accounts are often in the SNS sandbox, limiting sending to verified numbers and imposing spending caps. Request removal from the sandbox via AWS Support for production use.
Phone Number Formatting: SNS is strict about E.164 format (+ followed by country code and number, no dashes or spaces). Implement robust validation on input.
API_URL Mismatch: If the frontend can't reach the backend after deployment, verify the API_URL in .env.production matches the actual deployed API Gateway URL.
11. Verification
After deployment:
Access the CloudFront URL provided at the end of the yarn rw deploy serverless command.
Verify the HomePage loads, showing empty lists initially.
Use the UI to add a new Campaign with a test message.
Use the UI to add a new Subscriber with your own phone number (ensure it's your own number and in the required E.164 format, e.g., +15551234567).
Click the ""Send Campaign"" button for the test campaign.
Confirm you receive the SMS message on your phone.
Check the application UI for success/failure toasts.
(Optional) Check AWS CloudWatch Logs for the Lambda function associated with your GraphQL endpoint to see log messages from the sendCampaign service. Check SNS delivery logs in CloudWatch.
Next Steps
This guide provides a functional foundation. Potential enhancements include:
Authentication & Authorization: Secure the application properly.
Subscriber Management: Add features for editing, unsubscribing (and honoring the subscribed flag), and importing/exporting subscribers.
Campaign Scheduling: Implement background jobs (using Redwood Background Functions or external services like AWS Step Functions/EventBridge) to schedule campaigns.
Analytics: Track send times, delivery rates, and potentially link clicks (requires URL shortening and tracking).
Error Retries: Implement a retry mechanism (e.g., with exponential backoff) for failed SNS sends within the service or via background jobs.
Advanced SNS Features: Explore Sender IDs, Promotional vs Transactional types, and SNS Topics for segmenting subscribers.
Deployment Alternatives: Investigate alternative AWS deployment methods as they become recommended by RedwoodJS (e.g., Flightcontrol, SST, manual CDK/CloudFormation).
Frequently asked questions
You can send SMS messages using RedwoodJS by integrating with AWS SNS. Build a RedwoodJS app, configure AWS credentials, define your data models with Prisma, and implement a GraphQL API endpoint to trigger the sending process via the AWS SDK for JavaScript. This allows you to manage subscribers and send targeted campaigns directly from your application.
RedwoodJS is the core framework for building this full-stack, serverless-friendly application. It provides the structure for the frontend (React), backend (GraphQL API with Prisma ORM), and simplifies deployment to AWS Lambda and S3/CloudFront. Its integrated nature streamlines development and deployment processes.
AWS SNS is a managed messaging service chosen for its reliability, scalability, and direct SMS integration capabilities. It handles the complexities of sending messages at scale, allowing your RedwoodJS application to focus on campaign and subscriber management.
AWS credentials should be configured in the .env file at the project root during the initial setup. This file is used during development and is never committed to version control due to security concerns. For production, use .env.production which is created after the first deployment.
Yes, you can use SQLite, MySQL, or PostgreSQL as your database. Define your preferred provider in the api/db/schema.prisma file and update the DATABASE_URL in your .env file accordingly. Prisma, Redwood's ORM, supports multiple database providers.
Use the yarn rw deploy serverless command. Note: This approach with the serverless framework is deprecated. For the first deployment, the --first-run flag is required to set up API and Web sides and to correctly store the API_URL in the .env.production file.
Prisma is an ORM (Object-Relational Mapper) that simplifies database interactions. It's used to define data models (Campaign and Subscriber), manage database migrations, and generate the client used within the RedwoodJS services to query and modify the database.
The RedwoodJS documentation mentions that Serverless Framework deployment is deprecated in RedwoodJS v5+. While functional as of November 2023, monitor RedwoodJS announcements for updated deployment strategies to ensure compatibility.
Use Redwood's generators to scaffold CRUD operations (yarn rw g sdl Campaign --crud). Implement a sendCampaign mutation within api/src/graphql/campaigns.sdl.ts and its logic in api/src/services/campaigns/campaigns.ts using the AWS SDK for SNS. Return a SendCampaignResult type for detailed feedback.
The provided implementation sends messages individually in a loop for granular control and error handling. For large lists, consider SNS topics or batch sending for efficiency, although this may come with additional setup on the SNS side.
The recommended format for phone numbers when using SNS is the international E.164 format. This includes a plus sign followed by the country code and subscriber number, without any other characters like spaces or hyphens. For example, +15551234567.
Add the DATABASE_URL environment variable pointing to your production database in the .env.production file. This file is created after the first deployment. Do not commit this file to git because it contains sensitive information.
Never hardcode AWS credentials. Store them in .env during development and .env.production for deployment, and do not commit these files to version control. Use IAM roles for Lambda functions for enhanced security and least privilege principles, granting only necessary permissions like 'sns:Publish'.
Use try...catch blocks around API operations, log informative messages with Redwood's logger, return meaningful error messages through GraphQL without revealing sensitive data, and monitor CloudWatch for production issues.
Logs from your deployed RedwoodJS application, including Lambda function execution logs and SNS delivery status logs, can be found in AWS CloudWatch. Redwood's logger integrates with CloudWatch, providing structured logs for easier debugging.
This guide provides a step-by-step walkthrough for building a full-stack application using RedwoodJS that enables you to manage marketing campaigns and send SMS messages to subscribers via AWS Simple Notification Service (SNS).
We will build a simple web interface to manage lists of subscribers (names and phone numbers) and campaigns (names and message templates). You'll be able to trigger a campaign, which sends the specified message to all subscribed phone numbers using AWS SNS. This provides a foundation for a scalable SMS marketing platform.
Technologies Used:
System Architecture:
Prerequisites:
aws configure)By the end of this guide, you will have a deployed RedwoodJS application capable of managing subscribers and sending SMS campaigns via AWS SNS.
1. Project Setup and Configuration
Let's initialize our RedwoodJS project and set up the necessary configurations for AWS deployment.
Create RedwoodJS App: Open your terminal and run:
yarn create redwood-app redwood-sns-campaigns cd redwood-sns-campaignsFollow the prompts (choosing TypeScript is recommended).
Configure AWS CLI: If you haven't already, configure your AWS CLI with credentials that have permissions for SNS, Lambda, S3, CloudFront, and API Gateway. Running
aws configurewill prompt you for your Access Key ID, Secret Access Key, default region, and output format.aws configure # Follow prompts: # AWS Access Key ID [None]: YOUR_ACCESS_KEY_ID # AWS Secret Access Key [None]: YOUR_SECRET_ACCESS_KEY # Default region name [None]: us-east-1 # Or your preferred region # Default output format [None]: jsonWhy this region? Choose a region physically close to you or your users, and ensure SNS SMS support is available there.
us-east-1is a common default.Setup Serverless Deployment: RedwoodJS provides a setup command to configure deployment using the Serverless Framework for AWS.
This command adds
serverless.ymlconfiguration files to yourapiandwebdirectories and installs necessary dependencies.Note on Deprecation: As mentioned in the RedwoodJS v5+ documentation, this Serverless Framework deployment method is deprecated. While it still functions as of this writing, RedwoodJS is exploring alternative AWS deployment options. For production systems requiring long-term support, monitor RedwoodJS announcements for updated deployment strategies.
Configure AWS Credentials in
.env: The Serverless deployment needs your AWS credentials. Add them to your.envfile at the project root. Never commit your.envfile to version control. Redwood adds.envto.gitignoreby default.AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY: Obtain these from AWS IAM when creating a user with programmatic access. Security Best Practice: Grant this user the least privilege necessary. While broader permissions likeAmazonS3FullAccess,CloudFrontFullAccess,AWSLambda_FullAccess,AmazonAPIGatewayAdministrator, andAmazonSNSFullAccessmight be simpler for initial setup, they are less secure. For this specific application's core function, the minimum required permission for sending messages issns:Publish. You will need broader permissions for the deployment itself (handled by Serverless Framework using these credentials or potentially an IAM role), but the running Lambda function ideally only needssns:Publish(see Section 6). Start with broader permissions if needed during setup, but plan to restrict them significantly for production.AWS_REGION: The AWS region where your services (Lambda, SNS) will operate. Must match the region used duringaws configure.2. Database Schema and Data Layer
We'll use Prisma to define our database models for Campaigns and Subscribers.
Define Prisma Schema: Open
api/db/schema.prismaand define the models:Why these models?
Campaignstores the message content, andSubscriberstores contact information.phoneNumberis unique to avoid duplicates.subscribedallows for opt-outs later.Set Database URL: Ensure your
DATABASE_URLin.envpoints to your development database (e.g.,postgresql://user:password@localhost:5432/mydb). For SQLite (default), it's usuallyfile:./dev.db.Run Migrations: Apply the schema changes to your database and generate the Prisma client.
Enter a name for the migration when prompted (e.g.,
create_campaigns_subscribers).3. API Layer (GraphQL)
Now, let's build the GraphQL API endpoints to manage campaigns and subscribers, including the logic to send campaigns.
Generate GraphQL Scaffolding: Use Redwood's generators to create SDL (Schema Definition Language) files and services.
This creates basic CRUD (Create, Read, Update, Delete) operations for both models.
Implement Campaign Sending Logic: We need a mutation to trigger sending a specific campaign to all active subscribers.
Install AWS SDK for SNS: Add the SNS client package to your API workspace.
Define the Mutation SDL: Add the
sendCampaignmutation toapi/src/graphql/campaigns.sdl.ts.# api/src/graphql/campaigns.sdl.ts # Note: Redwood uses gql tagged template literals in TS files, # but the content itself is GraphQL SDL. type Campaign { id: Int! name: String! message: String! createdAt: DateTime! } type Query { campaigns: [Campaign!]! @requireAuth campaign(id: Int!): Campaign @requireAuth } input CreateCampaignInput { name: String! message: String! } input UpdateCampaignInput { name: String message: String } # Result type for the send operation type SendCampaignResult { successCount: Int! failureCount: Int! message: String! failedNumbers: [String!] } type Mutation { createCampaign(input: CreateCampaignInput!): Campaign! @requireAuth updateCampaign(id: Int!, input: UpdateCampaignInput!): Campaign! @requireAuth deleteCampaign(id: Int!): Campaign! @requireAuth # Add this mutation sendCampaign(id: Int!): SendCampaignResult! @requireAuth(roles: [""admin""]) # Example: Restrict access }Why
SendCampaignResult? It provides feedback to the user about the outcome of the send operation. Why@requireAuth(roles: [""admin""])? Sending campaigns often has costs and impact, so restricting it to specific user roles (once authentication is added) is crucial. We'll use basic@requireAuthfor now.Implement the Service Logic: Add the
sendCampaignfunction toapi/src/services/campaigns/campaigns.ts.// api/src/services/campaigns/campaigns.ts import { SNSClient, PublishCommand } from '@aws-sdk/client-sns' import type { QueryResolvers, MutationResolvers, CampaignResolvers, } from 'types/graphql' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' // Import Redwood logger import { requireAuth } from 'src/lib/auth' // Ensure user is logged in // Initialize SNS Client - Reads region and credentials from env vars automatically // Ensure AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY are in .env const snsClient = new SNSClient({}) export const campaigns: QueryResolvers['campaigns'] = () => { return db.campaign.findMany() } export const campaign: QueryResolvers['campaign'] = ({ id }) => { return db.campaign.findUnique({ where: { id }, }) } export const createCampaign: MutationResolvers['createCampaign'] = ({ input }) => { return db.campaign.create({ data: input, }) } export const updateCampaign: MutationResolvers['updateCampaign'] = ({ id, input }) => { return db.campaign.update({ data: input, where: { id }, }) } export const deleteCampaign: MutationResolvers['deleteCampaign'] = ({ id }) => { return db.campaign.delete({ where: { id }, }) } // Add this function export const sendCampaign: MutationResolvers['sendCampaign'] = async ({ id }) => { requireAuth({ roles: ['admin'] }) // Or adjust roles as needed const campaignToSend = await db.campaign.findUnique({ where: { id } }) if (!campaignToSend) { throw new Error(`Campaign with id ${id} not found.`) } const subscribers = await db.subscriber.findMany({ where: { subscribed: true }, // Only send to active subscribers }) if (subscribers.length === 0) { return { successCount: 0, failureCount: 0, message: 'No active subscribers found.', failedNumbers: [] } } let successCount = 0 let failureCount = 0 const failedNumbers: string[] = [] logger.info(`Starting campaign send for campaign ID: ${id} to ${subscribers.length} subscribers.`) for (const subscriber of subscribers) { // Basic validation - SNS requires E.164 format (e.g., +12223334444) if (!subscriber.phoneNumber || !/^\+\d{1,15}$/.test(subscriber.phoneNumber)) { logger.warn(`Skipping invalid phone number: ${subscriber.phoneNumber} for subscriber ID: ${subscriber.id}`) failureCount++ failedNumbers.push(subscriber.phoneNumber || 'MISSING_NUMBER') continue; } const params = { Message: campaignToSend.message, PhoneNumber: subscriber.phoneNumber, // Optional: Add MessageAttributes for SenderID, etc. // MessageAttributes: { // 'AWS.SNS.SMS.SenderID': { // 'DataType': 'String', // 'StringValue': 'MyBrand' // Requires registration in some regions // }, // 'AWS.SNS.SMS.SMSType': { // 'DataType': 'String', // 'StringValue': 'Transactional' // Or 'Promotional' // } // } } try { const command = new PublishCommand(params) await snsClient.send(command) logger.debug(`Successfully sent SMS to ${subscriber.phoneNumber} for campaign ID: ${id}`) successCount++ } catch (error) { logger.error(`Failed to send SMS to ${subscriber.phoneNumber}: ${error.message}`, error) failureCount++ failedNumbers.push(subscriber.phoneNumber) } } logger.info(`Finished campaign send for campaign ID: ${id}. Success: ${successCount}, Failures: ${failureCount}`) return { successCount, failureCount, message: `Campaign send complete. Success: ${successCount}, Failures: ${failureCount}.`, failedNumbers, } } export const Campaign: CampaignResolvers = { // If you need resolvers for related fields, add them here }Why loop and send individually? While SNS supports batch publishing to topics, sending individually to phone numbers gives granular control and error tracking per subscriber. For very large lists, consider SNS topics or batch APIs for efficiency. Why E.164 format? AWS SNS requires phone numbers in the international E.164 format (e.g.,
+15551234567) for reliable delivery. The code includes a basic regex check. Why Logging? Detailed logging helps diagnose issues during sending (e.g., invalid numbers, permission errors, SNS throttling).4. Frontend Implementation (React)
Let's build a simple UI to interact with our API.
Generate Pages and Components:
Implement
CampaignsCell: (web/src/components/CampaignsCell/CampaignsCell.tsx) Fetch and display campaigns, add a form, and include a button to trigger thesendCampaignmutation.// web/src/components/CampaignsCell/CampaignsCell.tsx import type { FindCampaigns, SendCampaignResult, SendCampaignMutationVariables } from 'types/graphql' // Added SendCampaignMutationVariables import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import CampaignForm from 'src/components/CampaignForm' // Assuming you create this export const QUERY = gql` query FindCampaigns { campaigns { id name message createdAt } } ` // Mutation for sending const SEND_CAMPAIGN_MUTATION = gql` mutation SendCampaignMutation($id: Int!) { sendCampaign(id: $id) { successCount failureCount message failedNumbers } } ` export const Loading = () => <div>Loading campaigns...</div> export const Empty = () => <div>No campaigns yet. <CampaignForm /></div> // Show form if empty export const Failure = ({ error }: CellFailureProps) => <div style={{ color: 'red' }}>Error: {error?.message}</div> export const Success = ({ campaigns }: CellSuccessProps<FindCampaigns>) => { const [sendCampaign, { loading }] = useMutation<SendCampaignResult, SendCampaignMutationVariables>( // Specify variable type SEND_CAMPAIGN_MUTATION, { onCompleted: (data) => { toast.success(`Campaign Send Status: ${data.sendCampaign.message}`) if (data.sendCampaign.failureCount > 0) { toast.error(`Failed to send to: ${data.sendCampaign.failedNumbers?.join(', ')}`) } }, onError: (error) => { toast.error(`Error sending campaign: ${error.message}`) }, // Optional: Refetch campaigns if needed, though not strictly necessary here // refetchQueries: [{ query: QUERY }], // awaitRefetchQueries: true, } ) const handleSendClick = (id: number, name: string) => { if (confirm(`Are you sure you want to send campaign ""${name}""?`)) { // Fixed quote escaping sendCampaign({ variables: { id } }) } } return ( <div> <h2>Campaigns</h2> <ul> {campaigns.map((campaign) => ( <li key={campaign.id}> <strong>{campaign.name}</strong>: ""{campaign.message}"" {/* Fixed quote escaping */} <button onClick={() => handleSendClick(campaign.id, campaign.name)} disabled={loading} style={{ marginLeft: '10px' }} > {loading ? 'Sending...' : 'Send Campaign'} </button> </li> ))} </ul> <hr /> <h3>Create New Campaign</h3> <CampaignForm /> {/* Assuming CampaignForm handles create mutation */} </div> ) }Why
useMutation? This Redwood hook simplifies calling GraphQL mutations and handling loading/error/completed states. Whytoast? Provides user feedback without disrupting the flow. Runyarn rw setup ui <your-preferred-library>(e.g.,chakra-ui) if you haven't already, or use browseralert().Implement
SubscribersCell: (web/src/components/SubscribersCell/SubscribersCell.tsx) Similar structure toCampaignsCell, displaying subscribers and includingSubscriberForm.// web/src/components/SubscribersCell/SubscribersCell.tsx import type { FindSubscribers } from 'types/graphql' import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web' import SubscriberForm from 'src/components/SubscriberForm' // Assuming you create this export const QUERY = gql` query FindSubscribers { subscribers { id name phoneNumber subscribed createdAt } } ` export const Loading = () => <div>Loading subscribers...</div> export const Empty = () => <div>No subscribers yet. <SubscriberForm /></div> export const Failure = ({ error }: CellFailureProps) => <div style={{ color: 'red' }}>Error: {error?.message}</div> export const Success = ({ subscribers }: CellSuccessProps<FindSubscribers>) => { return ( <div> <h2>Subscribers</h2> <ul> {subscribers.map((subscriber) => ( <li key={subscriber.id}> {subscriber.name || 'No Name'} - {subscriber.phoneNumber} ({subscriber.subscribed ? 'Subscribed' : 'Unsubscribed'}) {/* Add Edit/Delete buttons later if needed */} </li> ))} </ul> <hr /> <h3>Add New Subscriber</h3> <SubscriberForm /> {/* Assuming SubscriberForm handles create mutation */} </div> ) }Implement Forms: (
CampaignForm.tsx,SubscriberForm.tsx) Use Redwood's form helpers (@redwoodjs/forms) to create forms that call the respectivecreateCampaignandcreateSubscribermutations. Remember to include validation (e.g., required fields, phone number format). Refer to RedwoodJS Forms documentation for detailed implementation. Ensure the phone number input encourages or enforces the E.164 format (e.g., using a placeholder+15551234567).Update HomePage: (
web/src/pages/HomePage/HomePage.tsx) Include the cells on the main page.// web/src/pages/HomePage/HomePage.tsx import { MetaTags } from '@redwoodjs/web' import CampaignsCell from 'src/components/CampaignsCell' import SubscribersCell from 'src/components/SubscribersCell' const HomePage = () => { return ( <> <MetaTags title=""SNS Campaign Manager"" description=""Manage and send SMS campaigns"" /> {/* Fixed empty title */} <h1>SNS Campaign Manager</h1> <CampaignsCell /> <br /> <SubscribersCell /> </> ) } export default HomePage5. Deployment to AWS
Let's deploy the application using the configured Serverless setup.
First Deployment: The initial deployment requires a special flag to set up both API and Web sides correctly.
Add API_URL=<your-api-url> to .env.production? (Y/n). TypeYand press Enter. This saves the API endpoint URL so the web frontend knows where to send GraphQL requests in the deployed environment.Configure Production Environment Variables: After the first deploy, a
.env.productionfile is created. This file is critical for the deployed application and should NOT be committed to Git. You MUST add necessary environment variables here, as.envis not used in production deployment.Why manually add? The deploy command only adds
API_URL. Production database credentials and AWS keys/region needed by the running Lambda function (specifically, for the AWS SDK to interact with SNS at runtime) must be explicitly added here. These variables are packaged during the build/deploy process and made available to the Lambda environment. The credentials used by the Serverless Framework during deployment might be different (e.g., from your localaws configureor CI/CD environment) or could be the same, but the Lambda needs its own runtime access defined here.Apply Production Migrations: Connect to your production database and apply migrations. How you do this depends on your database hosting. You might SSH into a bastion host or use a managed database service's console. The command itself is:
# Run this command in an environment connected to your PRODUCTION database yarn rw prisma migrate deploySubsequent Deploys: For future updates after making code changes:
This command rebuilds and redeploys both the API and Web sides using the settings in
serverless.ymland variables from.env.production.Removing Your Deploy: To tear down the deployed AWS resources and avoid costs, run the
serverless removecommand in both theapiandwebdirectories.cd api yarn serverless remove --stage production # 'production' is the default stage cd ../web yarn serverless remove --stage production cd ..6. Security Considerations
yarn rw setup auth <provider>) to protect your API and UI. Restrict thesendCampaignmutation to specific roles (e.g.,admin) as shown in the code example.zodfor robust validation on both frontend forms and backend services (e.g., ensuringmessagecontent is safe, validating phone number formats strictly)..envand.env.productioncorrectly. Grant the IAM user the least privilege necessary (e.g., onlysns:Publishif that's all the running Lambda needs). Consider using IAM Roles for Lambda functions if deploying via methods other than Serverless Framework's default credential handling, as roles are generally more secure than long-lived access keys.sendCampaignmutation (and potentially others) to prevent abuse. This can be done within the service logic or using API Gateway features.7. Error Handling and Logging
try...catchblocks around critical operations like database calls and AWS SDK calls (snsClient.send).logger(import { logger } from 'src/lib/logger') for structured logging within your API services. Log informative messages for successful operations and detailed error messages/stack traces for failures. Levels (debug,info,warn,error) help filter logs later.8. Testing
sendCampaignlogic. Mock the Prisma client (@redwoodjs/testing/api) and the AWS SNS client (jest.mock('@aws-sdk/client-sns')) to test the logic without actual DB calls or sending SMS messages.yarn rw test apiyarn rw setup testing cypress) to test the full user flow from the UI.9. Monitoring and Observability
logger. Monitor SNS delivery status logs.10. Troubleshooting and Caveats
yarn rw setup deploy serverlessis deprecated. Monitor RedwoodJS for future AWS deployment recommendations. The current setup might break in future Redwood versions without community maintenance.Error: No auth.zip file found...: Often means the dev server (yarn rw dev) is running. Stop it and retry deployment.serverless.yml: If you add custom functions outside the standard GraphQL endpoint, you might need to manually add them toapi/serverless.yml.DATABASE_URL,AWS_REGION, and AWS credentials are correctly set in.env.productionand redeploy if changed.sns:Publishpermission.+followed by country code and number, no dashes or spaces). Implement robust validation on input.API_URLMismatch: If the frontend can't reach the backend after deployment, verify theAPI_URLin.env.productionmatches the actual deployed API Gateway URL.11. Verification
After deployment:
yarn rw deploy serverlesscommand.+15551234567).sendCampaignservice. Check SNS delivery logs in CloudWatch.Next Steps
This guide provides a functional foundation. Potential enhancements include:
subscribedflag), and importing/exporting subscribers.