code examples

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

Building an SMS Scheduler with Next.js and AWS EventBridge

A guide on creating a Next.js application to schedule SMS reminders using AWS EventBridge Scheduler, SNS, DynamoDB, and IAM.

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:

  1. Enter a message, a valid E.164 format phone number, and a future date/time.
  2. Submit the form to schedule the SMS reminder via an API endpoint.
  3. Receive the SMS message at the specified time.
  4. (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

bash
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

bash
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 for date-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.

dotenv
# .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:

typescript
// 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 using date-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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

  1. 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.
  2. 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 valid Date object.
  3. 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.
  4. 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 a try...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.
  5. Success Response: Returns a 201 Created response with a success message and the unique schedule name/ARN.
  6. 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:

typescript
// 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 and AWS_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:

json
// 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) the EVENTBRIDGE_SCHEDULER_ROLE_ARN when creating the schedule. The Condition ensures this role can only be passed to the EventBridge Scheduler service. Replace YourSchedulerExecutionRoleName with the actual name of the role created in the next step.
  • dynamodb:PutItem: Allows writing metadata to the DynamoDB table. Replace SmsSchedulesTableName 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).

  1. Go to IAM -> Roles -> Create role.
  2. Trusted entity type: Select "AWS service".
  3. Use case: Find and select "Scheduler". Click Next.
  4. Add permissions:
    • Search for and select the AmazonSNSFullAccess policy (for simplicity) OR create a more restrictive custom policy (Recommended):
      json
      // 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.
  5. Name, review, and create:
    • Role name: YourSchedulerExecutionRoleName (or similar - this name must match the ARN used in .env.local and the iam:PassRole resource).
    • Add a description (e.g., "Allows EventBridge Scheduler to publish SMS messages via SNS").
    • Create the role.
  6. Copy the Role ARN: Find the newly created role and copy its ARN. Paste this value into your .env.local file for EVENTBRIDGE_SCHEDULER_ROLE_ARN, ensuring the Account ID and Role Name are correct.

Step 3: Implement

Frequently Asked Questions

How to schedule SMS messages using Next.js?

You can schedule SMS messages by building a Next.js application that integrates with AWS EventBridge Scheduler. The frontend collects user input (phone number, message, time) and sends it to a Next.js API route. This route then interacts with AWS services to schedule and send the SMS at the specified time.

What is AWS EventBridge Scheduler used for?

AWS EventBridge Scheduler is a serverless service that handles the scheduling logic. It triggers AWS Simple Notification Service (SNS) to send the SMS message at the designated date and time, offloading this responsibility from the Next.js application.

Why use DynamoDB in SMS scheduler project?

DynamoDB, a NoSQL database, stores metadata about scheduled messages, such as the phone number, message content, and scheduled time. While optional, it's recommended for tracking, management, and potential future features. DynamoDB's Time-to-Live (TTL) feature helps automatically delete old records.

When to configure AWS credentials for Next.js app?

AWS credentials (access key ID and secret access key) should be configured during project setup. Store these securely in a `.env.local` file for local development and use more secure mechanisms like IAM roles for production environments. Do not hardcode these values directly into your Next.js code.

Can I use Zod for input validation in API routes?

Yes, Zod is recommended for validating user input on the server-side within your Next.js API routes. This ensures that data conforms to the expected format (E.164 phone numbers, valid date format) before being processed and sent to AWS services.

What is the role of NextAuth.js?

NextAuth.js handles user authentication for your Next.js application. It provides an easy way to protect the scheduling functionality by requiring users to sign in before accessing the form. This prevents unauthorized scheduling of messages.

How does IAM manage permissions in this project?

AWS Identity and Access Management (IAM) controls access to AWS services. You need to configure specific IAM permissions for both your Next.js backend and the EventBridge Scheduler's execution role. These permissions define which actions each entity is authorized to perform, ensuring security and preventing unintended access.

What are the prerequisites for building this SMS scheduler?

You will need Node.js, an AWS account, AWS CLI, and basic knowledge of React and Next.js. Familiarity with TypeScript, though not strictly necessary, is beneficial. A verified phone number might also be necessary depending on your SNS configuration (sandbox environment).

How to create EventBridge schedule with AWS SDK?

The `createEventBridgeSchedule` function in the API route handles the interaction with the AWS SDK. It creates a one-time schedule using the input data, assigns a unique name using a UUID, sets the scheduled time, and specifies the execution role with necessary permissions.

What AWS services are involved in SMS scheduling?

The key AWS services involved are EventBridge Scheduler, which triggers the message sending; SNS, which sends the actual SMS messages; DynamoDB for metadata storage (optional); and IAM, which manages access control and permissions.

How to validate phone number format in Next.js?

You can use a regular expression (regex) and a validation library like Zod in your Next.js API route to ensure phone numbers conform to the E.164 format. This provides reliable validation on the server-side, even if client-side checks are bypassed.

How to handle errors in Next.js API routes for AWS?

Use try-catch blocks to handle both Zod validation errors and errors from the AWS SDK or other issues. Return appropriate HTTP status codes (e.g., 400 for invalid input, 500 for server errors) with helpful error messages while also logging details server-side for debugging.

How to implement server-side validation with Zod?

Zod, a TypeScript-first schema validation library, is used in the `src/lib/validations.ts` file. You define schemas to enforce data types, constraints (like regex for phone numbers, minimum/maximum string lengths), and custom checks (ensuring the scheduled time is in the future).