Figure 1: System Architecture Diagram
This diagram illustrates the flow: A user interacts with the Next.js frontend, authenticating via NextAuth.js. Upon submitting a schedule request, the Next.js API route validates the data, stores metadata in DynamoDB, and creates a one-time schedule in EventBridge Scheduler using the AWS SDK. The API route requires specific IAM permissions to interact with AWS services. At the scheduled time, EventBridge Scheduler assumes its execution role (which needs sns:Publish
permission) and triggers SNS to send the SMS message to the user's phone number.
Project Overview and Goals
This guide details how to build a production-ready application using Next.js that enables users to schedule SMS reminders for future dates and times.
Problem Solved:
- Provides a mechanism to reliably send time-sensitive notifications (reminders, alerts) via SMS without requiring complex background job infrastructure.
- Offloads the scheduling logic to a robust, serverless AWS service (EventBridge Scheduler).
- Integrates seamlessly with a modern web framework (Next.js).
Technologies Used:
- Next.js: React framework for the frontend UI and backend API routes. Chosen for its developer experience, hybrid rendering capabilities, and integrated API layer.
- NextAuth.js: Authentication solution for Next.js. Chosen for its flexibility and ease of integration.
- AWS SDK for JavaScript v3: Used in the Next.js API route to interact with AWS services programmatically.
- AWS EventBridge Scheduler: Fully managed serverless scheduler used to trigger the SNS notification at the specified time. Chosen for its simplicity in handling one-time and recurring schedules targeted at AWS services.
- AWS Simple Notification Service (SNS): Managed messaging service used to send the actual SMS message. Chosen for its scalability and direct integration with EventBridge Scheduler.
- AWS DynamoDB: NoSQL database used to store metadata about scheduled messages (optional but recommended for tracking and potential management features). Chosen for its serverless nature, scalability, and integration with the AWS ecosystem. Includes Time-to-Live (TTL) for automatic cleanup.
- AWS Identity and Access Management (IAM): Manages permissions for AWS services. Essential for securely granting necessary access to the Next.js backend and EventBridge Scheduler.
- Tailwind CSS (Optional): Utility-first CSS framework for styling the frontend.
- Zod: TypeScript-first schema declaration and validation library. Used for validating API request payloads.
Final Outcome & Prerequisites:
By the end of this guide, you will have a Next.js application where authenticated users can:
- Enter a message, a valid E.164 format phone number, and a future date/time.
- Submit the form to schedule the SMS reminder via an API endpoint.
- Receive the SMS message at the specified time.
- (Optional) The application will store schedule metadata in DynamoDB and automatically clean up old records using TTL.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- An AWS account with appropriate permissions to create IAM roles/policies, SNS topics (optional), EventBridge schedules, and DynamoDB tables.
- AWS CLI installed and configured with credentials (or environment variables set up for the Next.js application).
- Basic understanding of React, Next.js, and asynchronous JavaScript.
- A verified phone number in your AWS account if operating in the SNS Sandbox environment (see Caveats section).
- Familiarity with TypeScript is beneficial.
1. Setting up the Project
Let's initialize the Next.js project and install necessary dependencies.
Step 1: Create Next.js App
npx create-next-app@latest sms-scheduler-app --ts --tailwind --eslint --app --src-dir --import-alias ""@/*""
cd sms-scheduler-app
This command scaffolds a new Next.js project using TypeScript, Tailwind CSS, ESLint, the App Router (--app
), a src/
directory, and @/*
import alias.
Step 2: Install Dependencies
npm install next-auth @aws-sdk/client-scheduler @aws-sdk/client-sns @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb zod react-datepicker @types/react-datepicker date-fns date-fns-tz uuid @types/uuid
# or
yarn add next-auth @aws-sdk/client-scheduler @aws-sdk/client-sns @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb zod react-datepicker @types/react-datepicker date-fns date-fns-tz uuid @types/uuid
next-auth
: Handles authentication.@aws-sdk/client-scheduler
: AWS SDK v3 client for EventBridge Scheduler.@aws-sdk/client-sns
: AWS SDK v3 client for SNS.@aws-sdk/client-dynamodb
,@aws-sdk/lib-dynamodb
: AWS SDK v3 clients for DynamoDB.zod
: For input validation.react-datepicker
,@types/react-datepicker
: UI component for selecting dates/times.date-fns
: Utility library for date manipulation.date-fns-tz
: Extension fordate-fns
to handle timezones correctly.uuid
,@types/uuid
: For generating unique schedule names.
Step 3: Configure Environment Variables
Create a file named .env.local
in the project root. Never commit this file to version control. Fill in your actual AWS details where indicated.
# .env.local
# AWS Credentials (Ensure the corresponding IAM user/role has necessary permissions)
# IMPORTANT: Replace with your actual AWS credentials for local development.
# For production, use secure methods like IAM Roles instead of hardcoding keys here.
# See Section 4, Step 2 for required permissions.
AWS_ACCESS_KEY_ID=YOUR_ACTUAL_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=YOUR_ACTUAL_AWS_SECRET_ACCESS_KEY
AWS_REGION=us-east-1 # Replace with your preferred AWS region (e.g., us-west-2)
# NextAuth Configuration
NEXTAUTH_URL=http://localhost:3000 # Change for production deployment URL
NEXTAUTH_SECRET= # Generate a strong secret using: openssl rand -base64 32
# Application Specific Variables
# IMPORTANT: Replace with the actual ARN and names of the AWS resources you create.
# These AWS resources will be created in later steps (see Section 4).
EVENTBRIDGE_SCHEDULER_ROLE_ARN=arn:aws:iam::YOUR_ACTUAL_AWS_ACCOUNT_ID:role/YourSchedulerExecutionRoleName # Replace ACCOUNT_ID and RoleName
DYNAMODB_SCHEDULES_TABLE_NAME=SmsSchedulesTableName # Replace with your chosen table name
# Optional: Specify an SNS Topic ARN if you prefer not to publish directly to phone numbers
# Replace YOUR_REGION, YOUR_ACTUAL_AWS_ACCOUNT_ID, and YourSnsTopicName if using this option.
# SNS_TOPIC_ARN=arn:aws:sns:YOUR_REGION:YOUR_ACTUAL_AWS_ACCOUNT_ID:YourSnsTopicName
- AWS Credentials: Provide keys for an IAM user or assume a role with permissions detailed below. For production, consider using IAM Roles for EC2/ECS/Lambda or other secure methods instead of hardcoding keys.
- AWS_REGION: The region where your EventBridge Scheduler, SNS, and DynamoDB resources will reside. Ensure consistency.
- NEXTAUTH_URL: The canonical URL of your application.
- NEXTAUTH_SECRET: A random string used to encrypt JWTs and session cookies. Generate one using the
openssl
command provided. - EVENTBRIDGE_SCHEDULER_ROLE_ARN: The ARN of the IAM Role EventBridge Scheduler will assume to publish to SNS. You will create this role and replace the placeholder value.
- DYNAMODB_SCHEDULES_TABLE_NAME: The name of the DynamoDB table for storing schedule metadata. You will create this table and replace the placeholder value.
Step 4: Project Structure and Architecture Decisions
We are using the Next.js App Router (src/app/
).
/src/app/api/schedule/route.ts
: Backend API endpoint to handle scheduling requests./src/app/page.tsx
: Main page with the scheduling form UI./src/app/layout.tsx
: Root layout, potentially including authentication providers./src/components/
: Reusable React components (e.g., SchedulerForm, DatePickerWrapper)./src/lib/
: Utility functions, AWS SDK client initialization, validation schemas./src/app/api/auth/[...nextauth]/route.ts
: NextAuth.js API route for authentication handling.
This structure separates concerns: frontend UI, backend API logic, authentication, shared utilities, and components. Using the App Router allows server components for efficiency and colocated API routes.
2. Implementing Core Functionality (Frontend UI)
Let's build the form for users to input scheduling details.
Step 1: Create the Scheduler Form Component
Create src/components/SchedulerForm.tsx
:
// src/components/SchedulerForm.tsx
'use client';
import React, { useState } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { formatISO } from 'date-fns';
export default function SchedulerForm() {
const [phoneNumber, setPhoneNumber] = useState('');
const [message, setMessage] = useState('');
const [scheduledAt, setScheduledAt] = useState<Date | null>(new Date());
const [isLoading, setIsLoading] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [isError, setIsError] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);
setStatusMessage('');
setIsError(false);
if (!scheduledAt) {
setStatusMessage('Please select a valid date and time.');
setIsError(true);
setIsLoading(false);
return;
}
// Basic validation (more robust validation happens server-side)
if (!phoneNumber || !message) {
setStatusMessage('Phone number and message are required.');
setIsError(true);
setIsLoading(false);
return;
}
// Ensure date is in the future (client-side check, server enforces)
if (scheduledAt <= new Date()) {
setStatusMessage('Scheduled time must be in the future.');
setIsError(true);
setIsLoading(false);
return;
}
try {
const response = await fetch('/api/schedule'_ {
method: 'POST'_
headers: {
'Content-Type': 'application/json'_
}_
body: JSON.stringify({
phoneNumber_
message_
// Send timestamp in UTC ISO format
scheduledAt: formatISO(scheduledAt)_
})_
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `HTTP error! status: ${response.status}`);
}
setStatusMessage(`Success! Message scheduled with ID: ${result.scheduleId}`);
setIsError(false);
// Optionally clear the form
// setPhoneNumber('');
// setMessage('');
// setScheduledAt(new Date());
} catch (error: any) {
console.error('Scheduling failed:'_ error);
setStatusMessage(`Scheduling failed: ${error.message}`);
setIsError(true);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Schedule SMS Reminder</h2>
<div>
<label htmlFor="phoneNumber" className="block text-sm font-medium text-gray-700 mb-1">
Phone Number (E.164 format)
</label>
<input
type="tel"
id="phoneNumber"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+12065550100"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="text-xs text-gray-500 mt-1">Include country code, e.g., +1 for US/Canada.</p>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
Message
</label>
<textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
required
maxLength={140} // Example limit
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="text-xs text-gray-500 mt-1">{message.length}/140 characters</p>
</div>
<div>
<label htmlFor="scheduledAt" className="block text-sm font-medium text-gray-700 mb-1">
Send At
</label>
<DatePicker
selected={scheduledAt}
onChange={(date: Date | null) => setScheduledAt(date)}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="MMMM d, yyyy h:mm aa"
minDate={new Date()} // Prevent selecting past dates
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
wrapperClassName="w-full"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-2 bg-indigo-600 text-white font-semibold rounded-md shadow hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Scheduling...' : 'Schedule Reminder'}
</button>
{statusMessage && (
<p className={`mt-4 text-sm ${isError ? 'text-red-600' : 'text-green-600'}`}>
{statusMessage}
</p>
)}
</form>
);
}
Explanation:
'use client';
: Marks this as a Client Component, necessary for using hooks (useState
) and event handlers.- State variables manage form inputs (
phoneNumber
,message
,scheduledAt
), loading state (isLoading
), and status feedback (statusMessage
,isError
). DatePicker
component provides the UI for selecting date and time.minDate
prevents selecting past dates.handleSubmit
:- Prevents default form submission.
- Sets loading state and clears previous status.
- Performs basic client-side checks (presence, future date).
- Formats the
scheduledAt
date to ISO 8601 UTC string usingdate-fns/formatISO
. This is crucial for consistency when sending to the backend and interacting with AWS services. - Sends a
POST
request to/api/schedule
with the form data in the body. - Handles the response, displaying success or error messages.
- Basic styling is included using Tailwind CSS classes.
- E.164 format (
+12223334444
) is requested for the phone number.
Step 2: Add Form to the Main Page
Update src/app/page.tsx
:
// src/app/page.tsx
import { getServerSession } from "next-auth/next"
import { authOptions } from "./api/auth/[...nextauth]/route" // We will create this file later
import SchedulerForm from "@/components/SchedulerForm";
import Link from "next/link";
export default async function HomePage() {
const session = await getServerSession(authOptions);
return (
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-gray-100">
<div className="w-full max-w-md">
{session ? (
<>
<p className="text-right mb-4 text-sm text-gray-600">
Signed in as {session.user?.email} | <Link href="/api/auth/signout" className="text-indigo-600 hover:underline">Sign out</Link>
</p>
<SchedulerForm />
</>
) : (
<div className="text-center p-6 bg-white rounded-lg shadow-md">
<h1 className="text-2xl font-semibold text-gray-800 mb-4">Welcome!</h1>
<p className="text-gray-600 mb-6">Please sign in to schedule SMS reminders.</p>
<Link href="/api/auth/signin" passHref legacyBehavior>
<a className="px-6 py-2 bg-indigo-600 text-white font-semibold rounded-md shadow hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Sign In
</a>
</Link>
</div>
)}
</div>
</main>
);
}
Explanation:
- This is a Server Component by default.
getServerSession
checks if the user is authenticated on the server.- If authenticated, it displays a sign-out link and renders the
SchedulerForm
. - If not authenticated, it prompts the user to sign in.
3. Building a Complete API Layer
Now, let's create the Next.js API route that handles the scheduling logic.
Step 1: Define Validation Schema
Create src/lib/validations.ts
:
// src/lib/validations.ts
import { z } from 'zod';
// E.164 regex (simplified, adjust for stricter validation if needed)
const phoneRegex = /^\+[1-9]\d{1,14}$/;
export const scheduleSchema = z.object({
// Validate E.164 format
phoneNumber: z.string().regex(phoneRegex, { message: ""Invalid phone number format. Use E.164 (e.g., +12065550100)."" }),
message: z.string().min(1, { message: ""Message cannot be empty."" }).max(1600, { message: ""Message too long."" }), // SNS limit is higher, but often constrained by carrier/client
scheduledAt: z.string().datetime({ message: ""Invalid date format. Use ISO 8601."" })
.refine((date) => new Date(date) > new Date(), {
message: ""Scheduled time must be in the future."",
}),
});
export type ScheduleInput = z.infer<typeof scheduleSchema>;
Explanation:
- Uses Zod to define the expected shape and constraints of the incoming request body.
phoneNumber
: Must be a string matching the E.164 format.message
: Must be a non-empty string, with a max length (adjust as needed).scheduledAt
: Must be a valid ISO 8601 datetime string and represent a future time.
Step 2: Implement the API Route
Create src/app/api/schedule/route.ts
:
// src/app/api/schedule/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; // Adjust path if needed
import { ZodError } from 'zod';
import { scheduleSchema, ScheduleInput } from '@/lib/validations';
import { createEventBridgeSchedule } from '@/lib/aws/scheduler'; // We'll create this next
import { saveScheduleMetadata } from '@/lib/aws/dynamodb'; // We'll create this next
import { v4 as uuidv4 } from 'uuid'; // Import UUID generator
export async function POST(request: Request) {
const session = await getServerSession(authOptions);
// 1. Authentication Check
if (!session || !session.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userEmail = session.user.email; // Get user identifier
try {
const body = await request.json();
// 2. Input Validation
const validatedData = scheduleSchema.parse(body);
// Ensure scheduledAt is valid Date object before further processing
const scheduledAtDate = new Date(validatedData.scheduledAt);
if (isNaN(scheduledAtDate.getTime())) {
throw new Error(""Invalid scheduled date provided."");
}
// 3. Create EventBridge Schedule
// Generate a unique name. Using UUID ensures uniqueness and avoids length issues.
// Prefixing helps identify schedules in the AWS console.
// Note: EventBridge schedule names have a 64-character limit.
const uniqueSuffix = uuidv4();
const prefix = `sms-${userEmail.replace(/[^a-zA-Z0-9-]/g, '-').substring(0, 20)}`; // Sanitize and truncate email part
let scheduleName = `${prefix}-${uniqueSuffix}`;
scheduleName = scheduleName.substring(0, 64); // Enforce 64 char limit
const scheduleResult = await createEventBridgeSchedule({
name: scheduleName,
scheduleAt: scheduledAtDate,
phoneNumber: validatedData.phoneNumber,
message: validatedData.message,
});
// 4. Store Metadata (Optional but Recommended)
try {
await saveScheduleMetadata({
scheduleArn: scheduleResult.scheduleArn,
userEmail: userEmail,
phoneNumber: validatedData.phoneNumber,
message: validatedData.message,
scheduledAt: scheduledAtDate,
});
} catch (dbError: any) {
console.warn(`DynamoDB write failed for schedule ${scheduleResult.scheduleArn}: ${dbError.message}. Continuing as schedule was created.`);
// Decide if you want to attempt deleting the schedule here if DB write fails critically
// await deleteEventBridgeSchedule(scheduleName); // Need to implement delete function
// return NextResponse.json({ error: 'Failed to save schedule metadata' }, { status: 500 });
}
// 5. Return Success Response
return NextResponse.json({
message: 'SMS scheduled successfully!',
scheduleId: scheduleName, // Or return scheduleArn
scheduleArn: scheduleResult.scheduleArn
}, { status: 201 }); // 201 Created
} catch (error: any) {
console.error(""API Error:"", error);
// Handle Zod validation errors
if (error instanceof ZodError) {
return NextResponse.json(
{ error: 'Invalid input data.', details: error.errors },
{ status: 400 }
);
}
// Handle AWS or other errors
// Log error details for debugging
console.error(""Detailed Error:"", error.message, error.stack);
// Provide a generic error message to the client
return NextResponse.json(
{ error: error.message || 'Failed to schedule SMS.' },
{ status: 500 } // Internal Server Error
);
}
}
Explanation:
- Authentication: Retrieves the server session using
getServerSession
. If no valid session exists, it returns a 401 Unauthorized error. The user's email is extracted as an identifier. - Input Validation: Parses and validates the incoming JSON request body against the
scheduleSchema
using Zod. If validation fails, it returns a 400 Bad Request error with details. It also double-checks if the parsed date string results in a validDate
object. - Create Schedule: Calls a helper function
createEventBridgeSchedule
(defined in the next section) to interact with the AWS SDK and create the one-time schedule in EventBridge. A unique name is generated using a sanitized prefix from the user email and a UUID (uuidv4
) to ensure uniqueness and avoid collisions. The name is truncated to 64 characters to comply with EventBridge limits. - Store Metadata: Calls
saveScheduleMetadata
(defined later) to save details about the schedule (like the EventBridge ARN, user, phone number, etc.) to DynamoDB. This step is wrapped in atry...catch
to handle potential database errors without necessarily failing the entire request if the schedule itself was created successfully. You might add logic here to clean up the EventBridge schedule if the DB write is critical and fails. - Success Response: Returns a 201 Created response with a success message and the unique schedule name/ARN.
- Error Handling: Includes
try...catch
blocks to handle Zod errors specifically and generic errors (AWS SDK errors, network issues), logging them server-side and returning appropriate HTTP status codes (400 for validation, 500 for server errors).
4. Integrating with AWS Services
Now, let's implement the logic to interact with EventBridge Scheduler, SNS (implicitly via Scheduler), IAM, and DynamoDB.
Step 1: Configure AWS SDK Clients
Create src/lib/aws/clients.ts
:
// src/lib/aws/clients.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { SchedulerClient } from "@aws-sdk/client-scheduler";
// SNS Client isn't directly needed here as EventBridge invokes it,
// but you might need it for other operations (e.g., topic creation, direct publish)
// import { SNSClient } from "@aws-sdk/client-sns";
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 generally required
console.error("AWS_REGION environment variable is not set. SDK calls may fail.");
// Optionally throw an error depending on your setup strategy
// throw new Error("AWS_REGION environment variable is not set.");
}
if (!accessKeyId || !secretAccessKey) {
// In production, rely on IAM roles or other secure credential methods (e.g., Vercel env vars, EC2 instance profile)
// The SDK will attempt the default credential chain if keys are not explicitly provided.
if (process.env.NODE_ENV !== 'production') {
console.warn("AWS Access Key ID or Secret Access Key not explicitly configured via environment variables. SDK will attempt default credential chain (e.g., ~/.aws/credentials, IAM role). Ensure credentials are available.");
} else {
// In production, it's expected that credentials come from the environment (IAM role, etc.)
console.log("AWS credentials not found in environment variables. Assuming IAM role or other secure mechanism provides credentials.");
}
}
const credentials = (accessKeyId && secretAccessKey)
? { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey }
: undefined; // Let SDK use default chain if keys aren't provided
// DynamoDB Client
const ddbClient = new DynamoDBClient({
region,
credentials,
});
const ddbDocClient = DynamoDBDocumentClient.from(ddbClient);
// EventBridge Scheduler Client
const schedulerClient = new SchedulerClient({
region,
credentials,
});
// Export initialized clients
export { ddbDocClient, schedulerClient };
// Helper function to get required environment variables, throwing if missing
export function getEnvVariable(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Required environment variable ${key} is not set.`);
}
return value;
}
Explanation:
- Initializes AWS SDK v3 clients for DynamoDB (using the simplified
DynamoDBDocumentClient
) and EventBridge Scheduler. - Reads AWS region and credentials from environment variables defined in
.env.local
. - Includes checks and logs messages if credentials or region aren't explicitly set, explaining that the SDK will try the default credential chain. Important: In production, avoid relying on
AWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
environment variables directly in your application code. Use IAM roles associated with your compute environment (like Vercel's Environment Variables, EC2 Instance Profiles, Lambda Execution Roles, ECS Task Roles) for better security. The SDK will automatically pick up credentials from these sources if the environment variables are not explicitly set. - Exports the initialized clients for use in other modules.
- Includes a helper
getEnvVariable
to safely access required environment variables, ensuring they are present.
Step 2: Create IAM Roles and Policies
This is a critical step requiring configuration in the AWS Console or via AWS CLI/IaC. Remember to replace placeholders like YOUR_REGION
, YOUR_AWS_ACCOUNT_ID
, YourSchedulerExecutionRoleName
, and SmsSchedulesTableName
with your actual values.
A. IAM Permissions for the Next.js API Route (Backend Function)
The IAM user/role whose credentials (AWS_ACCESS_KEY_ID
, AWS_SECRET_ACCESS_KEY
) are used by the Next.js backend (or the role assumed by the compute environment like Vercel/Lambda) needs the following permissions:
// Policy: NextJsBackendPermissions
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCreateEventBridgeSchedules",
"Effect": "Allow",
"Action": [
"scheduler:CreateSchedule",
"scheduler:GetSchedule" // Optional: useful for checking existence/status
// "scheduler:DeleteSchedule" // Add if you implement cancellation
],
"Resource": [
"arn:aws:scheduler:YOUR_REGION:YOUR_AWS_ACCOUNT_ID:schedule/default/*", // Scope down if possible (e.g., using your prefix 'sms-*')
"arn:aws:scheduler:YOUR_REGION:YOUR_AWS_ACCOUNT_ID:schedule/YOUR_SCHEDULE_GROUP_NAME/*" // If using schedule groups
]
},
{
"Sid": "AllowPassRoleToEventBridgeScheduler",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/YourSchedulerExecutionRoleName", // MUST match the ARN used for EVENTBRIDGE_SCHEDULER_ROLE_ARN env var
"Condition": {
"StringEquals": {
"iam:PassedToService": "scheduler.amazonaws.com"
}
}
},
{
"Sid": "AllowWriteToDynamoDB",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:UpdateItem" // If you implement updates
// "dynamodb:Query", // Add if you implement listing schedules
// "dynamodb:DeleteItem" // Add if needed
],
"Resource": "arn:aws:dynamodb:YOUR_REGION:YOUR_AWS_ACCOUNT_ID:table/SmsSchedulesTableName" // MUST match the DYNAMODB_SCHEDULES_TABLE_NAME env var
}
]
}
Explanation:
scheduler:CreateSchedule
: Allows creating new schedules. Resource ARN should ideally be scoped to a specific group or naming pattern (e.g.,arn:aws:scheduler:YOUR_REGION:YOUR_AWS_ACCOUNT_ID:schedule/default/sms-*
).iam:PassRole
: Crucial. Allows the Next.js backend to specify (pass
) theEVENTBRIDGE_SCHEDULER_ROLE_ARN
when creating the schedule. TheCondition
ensures this role can only be passed to the EventBridge Scheduler service. ReplaceYourSchedulerExecutionRoleName
with the actual name of the role created in the next step.dynamodb:PutItem
: Allows writing metadata to the DynamoDB table. ReplaceSmsSchedulesTableName
with your actual table name.
B. IAM Execution Role for EventBridge Scheduler
EventBridge Scheduler needs its own role to assume when it executes the task (publishing to SNS).
- Go to IAM -> Roles -> Create role.
- Trusted entity type: Select "AWS service".
- Use case: Find and select "Scheduler". Click Next.
- Add permissions:
- Search for and select the
AmazonSNSFullAccess
policy (for simplicity) OR create a more restrictive custom policy (Recommended):// Policy: SchedulerSNSPublishPolicy { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowPublishToSNS", "Effect": "Allow", "Action": "sns:Publish", "Resource": "*" // Allows publishing to any phone number or topic // OR restrict to a specific topic if using SNS_TOPIC_ARN: // "Resource": "arn:aws:sns:YOUR_REGION:YOUR_AWS_ACCOUNT_ID:YourSnsTopicName" } ] }
- Attach this custom policy if created.
- Search for and select the
- Name, review, and create:
- Role name:
YourSchedulerExecutionRoleName
(or similar - this name must match the ARN used in.env.local
and theiam:PassRole
resource). - Add a description (e.g., "Allows EventBridge Scheduler to publish SMS messages via SNS").
- Create the role.
- Role name:
- Copy the Role ARN: Find the newly created role and copy its ARN. Paste this value into your
.env.local
file forEVENTBRIDGE_SCHEDULER_ROLE_ARN
, ensuring the Account ID and Role Name are correct.
Step 3: Implement