code examples

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

Twilio SMS Scheduling with Next.js: Build Automated Appointment Reminders

Learn how to build automated SMS appointment reminders using Twilio Message Scheduling, Next.js, and Node.js. Complete guide with code examples, Prisma database integration, and deployment instructions for production-ready reminder systems.

Twilio SMS Scheduling with Next.js: Build Automated Appointment Reminders

Learn how to build a robust application using Next.js and Node.js to schedule appointments and automatically send SMS reminders via Twilio's Message Scheduling feature. This guide provides a complete walkthrough, from project setup to deployment and verification.

This approach leverages Twilio's built-in scheduling, eliminating the need for custom cron jobs or background workers to manage reminder sending times. You create the appointment, tell Twilio when to send the reminder, and Twilio handles the rest.

Project Overview and Goals

What You're Building:

A web application where users can:

  1. Schedule future appointments, specifying details like name, phone number, appointment date/time, and time zone.
  2. Automatically trigger an SMS reminder via Twilio, scheduled to be sent a predefined time (e.g., 1 hour) before the appointment.

Problem Solved:

Automates the process of sending timely appointment reminders, reducing no-shows and improving customer communication without complex scheduling infrastructure on your backend.

Technologies Used:

  • Next.js: React framework for frontend UI and API routes (backend logic). Chosen for its integrated frontend/backend development experience, performance optimizations, and ease of deployment.
  • Node.js: The runtime environment for Next.js.
  • Twilio Programmable Messaging: Used via the official Node.js helper library to send SMS messages.
  • Twilio Message Scheduling: A feature of Twilio Messaging used to schedule SMS/MMS messages for future delivery (requires a Messaging Service).
  • Prisma: Modern ORM (Object-Relational Mapping) for database access (using PostgreSQL in this guide). Chosen for its type safety, migration management, and developer experience.
  • PostgreSQL: A robust open-source relational database.
  • Zod: TypeScript-first schema declaration and validation library. Used for validating API request payloads.

System Architecture:

The following diagram illustrates the flow:

mermaid
graph LR
    A[User's Browser] -- HTTP Request --> B(Next.js App / Vercel);
    B -- Enters Appointment Details --> C{Frontend UI (React)};
    C -- POST /api/appointments --> D[Next.js API Route];
    D -- Validate Input --> D;
    D -- Save Appointment (UTC) --> E{Prisma Client};
    E -- Writes to --> F[(PostgreSQL DB)];
    D -- Calculate Reminder Time --> D;
    D -- Schedule SMS --> G[Twilio Client];
    G -- Send Schedule Request --> H(Twilio API / Message Scheduling);

    %% Styling
    style A fill:#f9f,stroke:#333,stroke-width:2px;
    style B fill:#ccf,stroke:#333,stroke-width:2px;
    style C fill:#cdf,stroke:#333,stroke-width:2px;
    style D fill:#cdf,stroke:#333,stroke-width:2px;
    style E fill:#f9d,stroke:#333,stroke-width:2px;
    style F fill:#ddf,stroke:#333,stroke-width:2px;
    style G fill:#f9d,stroke:#333,stroke-width:2px;
    style H fill:#f90,stroke:#333,stroke-width:2px;

(Note: The above Mermaid diagram shows the user interacting with the Next.js frontend, which sends data to a Next.js API route. The API route validates input, saves data to PostgreSQL via Prisma, calculates the reminder time, and uses the Twilio client to schedule an SMS via Twilio's Message Scheduling API.)

Outcome:

A functional Next.js application deployed (e.g., on Vercel) capable of accepting appointment details and reliably scheduling SMS reminders via Twilio.

Prerequisites:

  • Node.js v20.x or v22.x LTS installed (v22.x recommended – Active LTS through April 2027, v20.x in Maintenance LTS through April 2026) and npm/yarn installed.
  • A Twilio account (Sign up for free).
  • An active Twilio phone number capable of sending SMS (Buy a number).
  • A Twilio Messaging Service configured with your Twilio phone number (detailed below – mandatory for Message Scheduling).
  • Access to a PostgreSQL database (local or cloud-hosted).
  • A Vercel account (or similar hosting platform) for deployment.
  • Basic understanding of React, Next.js (v15.x or later recommended), TypeScript, and REST APIs.

1. Setting up the Project

Initialize your Next.js project using TypeScript and install necessary dependencies.

  1. Create Next.js App: Open your terminal and run:

    bash
    npx create-next-app@latest --typescript appointment-reminders-nextjs
    cd appointment-reminders-nextjs
  2. Install Dependencies: Install Prisma for database interactions, the Twilio helper library, and Zod for validation.

    bash
    npm install prisma @prisma/client twilio zod date-fns date-fns-tz
    npm install --save-dev @types/node typescript ts-node @types/react @types/react-dom
    • prisma: The Prisma CLI tool.
    • @prisma/client: The auto-generated, type-safe database client.
    • twilio: Official Twilio Node.js helper library.
    • zod: For data validation.
    • date-fns / date-fns-tz: Robust libraries for date/time manipulation and time zone handling.

    Current Package Versions (2025):

    • Prisma: v6.16.0+ is current stable (Prisma ORM 6.x series). Prisma 6 introduces a Rust-free architecture preview for PostgreSQL and SQLite with improved performance for large datasets. Use @prisma/client v6.x for type-safe database access.
    • Twilio: Latest Node.js helper library (twilio npm package) fully supports Message Scheduling with scheduleType and sendAt parameters.
    • Next.js: v15.2+ is current stable (February 2025), supporting React 19, Turbopack improvements, and enhanced caching. This guide uses Pages Router for clarity, but App Router (stable in Next.js 13.4+) is recommended for new projects.
    • Zod: Latest version for TypeScript-first schema validation.
    • date-fns: Latest version with date-fns-tz for time zone support.
  3. Initialize Prisma: Set up Prisma with PostgreSQL as the provider.

    bash
    npx prisma init --datasource-provider postgresql

    This creates a prisma directory with a schema.prisma file and a .env file for your database connection string.

  4. Configure Environment Variables: Prisma added DATABASE_URL to .env. Add Twilio variables as well. Rename .env to .env.local (which Next.js uses and ignores in Git by default).

    .env.local

    dotenv
    # Prisma Database Connection String (Update with your actual DB connection details)
    # Example for local PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE
    # WARNING: Replace YOUR_DB_USER and YOUR_DB_PASSWORD with secure credentials. Do not use defaults in production.
    DATABASE_URL="postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@localhost:5432/reminders"
    
    # Twilio Credentials
    # Found in your Twilio Console Dashboard: https://www.twilio.com/console
    TWILIO_ACCOUNT_SID="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    TWILIO_AUTH_TOKEN="your_auth_token_xxxxxxxxxxxxxx"
    
    # Twilio Messaging Service SID
    # Found in Console > Messaging > Services. Starts with 'MG'.
    # MANDATORY for Message Scheduling.
    TWILIO_MESSAGING_SERVICE_SID="MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    
    # Optional: Your Twilio phone number (mainly for reference/debugging)
    # The 'From' number is determined by the Messaging Service pool.
    TWILIO_PHONE_NUMBER="+15551234567"
    • DATABASE_URL: Replace the example with your actual PostgreSQL connection string, ensuring you use strong, unique credentials (YOUR_DB_USER, YOUR_DB_PASSWORD) and that the database (reminders in the example) exists. Never commit default or insecure credentials.
    • TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN: Obtain these from your Twilio Console Dashboard under "Account Info". Treat the Auth Token like a password – keep it secret.
    • TWILIO_MESSAGING_SERVICE_SID: This is critical for using Message Scheduling. You must create a Messaging Service and add your Twilio phone number to its sender pool. See Section 4 for detailed steps. Find the SID (starting with MG) on the Messaging Services page.
  5. Project Structure: Your basic structure will look like this:

    appointment-reminders-nextjs/ ├── prisma/ │ └── schema.prisma # Database schema definition ├── pages/ │ ├── api/ │ │ └── appointments.ts # API route for creating appointments │ ├── _app.tsx │ └── index.tsx # Frontend page with the form ├── public/ ├── styles/ ├── .env.local # Environment variables (ignored by Git) ├── next.config.js ├── package.json ├── tsconfig.json └── ... other config files

2. Implementing Core Functionality (Frontend Form)

Create a simple form on the homepage (pages/index.tsx) to capture appointment details.

pages/index.tsx

typescript
import { useState, FormEvent } from 'react';
import { format } from 'date-fns';
import styles from '../styles/Home.module.css'; // Optional: for basic styling

// Basic list of time zones for the dropdown
const timeZones = Intl.DateTimeFormat().resolvedOptions().timeZone ? // Use browser default if available
  [Intl.DateTimeFormat().resolvedOptions().timeZone, 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Berlin']
  : ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Berlin']; // Fallback list

export default function Home() {
  const [name, setName] = useState('');
  const [phoneNumber, setPhoneNumber] = useState('');
  const [appointmentDate, setAppointmentDate] = useState(''); // Store as string YYYY-MM-DD
  const [appointmentTime, setAppointmentTime] = useState(''); // Store as string HH:MM (24hr)
  const [timeZone, setTimeZone] = useState(timeZones[0]); // Default to first timezone
  const [statusMessage, setStatusMessage] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    setIsLoading(true);
    setStatusMessage('');

    // Combine date and time into a single string for the API
    const localAppointmentTimeString = `${appointmentDate}T${appointmentTime}:00`;

    try {
      const response = await fetch('/api/appointments', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          name,
          phoneNumber,
          // Send combined datetime string and timezone separately
          appointmentTime: localAppointmentTimeString,
          timeZone,
        }),
      });

      const result = await response.json();

      if (!response.ok) {
        throw new Error(result.error || 'Failed to schedule appointment');
      }

      setStatusMessage(`Appointment for ${name} scheduled! Reminder Message SID: ${result.messageSid}`);
      // Clear form
      setName('');
      setPhoneNumber('');
      setAppointmentDate('');
      setAppointmentTime('');
      setTimeZone(timeZones[0]);

    } catch (error: any) {
      console.error('Submission error:', error);
      setStatusMessage(`Error: ${error.message}`);
    } finally {
      setIsLoading(false);
    }
  };

  // Get today's date in YYYY-MM-DD format for min attribute
  const today = format(new Date(), 'yyyy-MM-dd');

  return (
    <div className={styles.container}> {/* Optional styling */}
      <main className={styles.main}> {/* Optional styling */}
        <h1 className={styles.title}>Schedule Appointment Reminder</h1> {/* Optional styling */}

        <form onSubmit={handleSubmit} className={styles.form}> {/* Optional styling */}
          <div className={styles.formGroup}>
            <label htmlFor="name">Name:</label>
            <input
              type="text"
              id="name"
              value={name}
              onChange={(e) => setName(e.target.value)}
              required
            />
          </div>

          <div className={styles.formGroup}>
            <label htmlFor="phoneNumber">Phone Number (E.164 format):</label>
            <input
              type="tel"
              id="phoneNumber"
              value={phoneNumber}
              onChange={(e) => setPhoneNumber(e.target.value)}
              placeholder="+15551234567"
              pattern="\+[1-9]\d{1,14}" // Basic E.164 pattern
              required
            />
            <small>Include country code, e.g., +1 for US/Canada.</small>
          </div>

          <div className={styles.formGroup}>
            <label htmlFor="appointmentDate">Appointment Date:</label>
            <input
              type="date"
              id="appointmentDate"
              value={appointmentDate}
              onChange={(e) => setAppointmentDate(e.target.value)}
              min={today} // Prevent scheduling in the past
              required
            />
          </div>

           <div className={styles.formGroup}>
             <label htmlFor="appointmentTime">Appointment Time (24hr):</label>
             <input
               type="time"
               id="appointmentTime"
               value={appointmentTime}
               onChange={(e) => setAppointmentTime(e.target.value)}
               required
             />
           </div>

          <div className={styles.formGroup}>
            <label htmlFor="timeZone">Time Zone:</label>
            <select
              id="timeZone"
              value={timeZone}
              onChange={(e) => setTimeZone(e.target.value)}
              required
            >
              {timeZones.map((tz) => (
                <option key={tz} value={tz}>
                  {tz}
                </option>
              ))}
            </select>
          </div>

          <button type="submit" disabled={isLoading} className={styles.button}>
            {isLoading ? 'Scheduling…' : 'Schedule Reminder'}
          </button>
        </form>

        {statusMessage && <p className={styles.statusMessage}>{statusMessage}</p>}
      </main>
    </div>
  );
}
  • This component provides inputs for name, phone number (using type="tel" with a basic E.164 pattern), appointment date (type="date"), time (type="time"), and a dropdown for the time zone.
  • It combines date and time inputs before sending them to the API.
  • It handles the form submission asynchronously, calls the backend API endpoint (/api/appointments), and displays status or error messages.
  • Basic client-side validation (required, pattern, min date) is included.

Add basic CSS in styles/Home.module.css for better presentation.

3. Building the API Layer

Create the Next.js API route that handles appointment creation and schedules the Twilio SMS reminder.

pages/api/appointments.ts

typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
import Twilio from 'twilio';
import { z } from 'zod';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
import { add, sub, isBefore, isValid, parseISO } from 'date-fns';

const prisma = new PrismaClient();

// Input validation schema using Zod
const appointmentSchema = z.object({
  name: z.string().min(1, { message: "Name cannot be empty" }),
  // Basic E.164 validation - improve as needed for production
  phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, { message: "Invalid phone number format (must be E.164)" }),
  appointmentTime: z.string().refine((time) => isValid(parseISO(time)), { // Check if it's a valid ISO-like string (YYYY-MM-DDTHH:mm:ss)
      message: "Invalid appointment date/time format",
  }),
  timeZone: z.string().refine((tz) => { // Basic check if timezone seems valid
    try {
      Intl.DateTimeFormat(undefined, { timeZone: tz });
      return true;
    } catch (ex) {
      return false;
    }
  }, { message: "Invalid time zone" }),
});

// Twilio client initialization (ensure environment variables are set!)
const accountSid = process.env.TWILIO_ACCOUNT_SID;
const authToken = process.env.TWILIO_AUTH_TOKEN;
const messagingServiceSid = process.env.TWILIO_MESSAGING_SERVICE_SID;

// Validate environment variables
if (!accountSid || !authToken || !messagingServiceSid) {
  console.error("Twilio environment variables missing!");
  // Avoid creating the client if variables are missing to prevent runtime errors elsewhere
}
// Only create client if variables are present
const twilioClient = accountSid && authToken ? Twilio(accountSid, authToken) : null;


// --- Main API Handler ---
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', ['POST']);
    return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
  }

  if (!twilioClient || !messagingServiceSid) {
      return res.status(500).json({ error: "Twilio configuration missing on server." });
  }

  // 1. Validate Input
  const validationResult = appointmentSchema.safeParse(req.body);
  if (!validationResult.success) {
    return res.status(400).json({ error: "Invalid input", details: validationResult.error.flatten() });
  }

  const { name, phoneNumber, appointmentTime: localAppointmentTimeString, timeZone } = validationResult.data;

  try {
    // 2. Time Zone Conversion & Scheduling Logic
    const localAppointmentTime = parseISO(localAppointmentTimeString); // Parse the combined string
    const appointmentTimeUTC = zonedTimeToUtc(localAppointmentTime, timeZone);

    // Calculate reminder time (e.g., 1 hour before appointment)
    const reminderTimeUTC = sub(appointmentTimeUTC, { hours: 1 });
    const nowUTC = new Date(); // Current time in UTC

    // Twilio scheduling constraints: minimum 15 minutes, maximum 35 days from now
    // Per Twilio Message Scheduling documentation (2024-2025)
    const minScheduleTime = add(nowUTC, { minutes: 15 });
    const maxScheduleTime = add(nowUTC, { days: 35 });

    if (isBefore(reminderTimeUTC, minScheduleTime)) {
      return res.status(400).json({ error: `Calculated reminder time (${reminderTimeUTC.toISOString()}) is too soon. Must be at least 15 minutes from now per Twilio requirements.` });
    }
    if (isBefore(maxScheduleTime, reminderTimeUTC)) {
       return res.status(400).json({ error: `Calculated reminder time (${reminderTimeUTC.toISOString()}) is too far in the future. Must be within 35 days from now per Twilio requirements.` });
    }

    // 3. Save Appointment to Database (using UTC time)
    const appointment = await prisma.appointment.create({
      data: {
        name,
        phoneNumber,
        appointmentTime: appointmentTimeUTC, // Store UTC time
        timeZone, // Store original timezone for context if needed
        status: 'SCHEDULED', // Initial status
      },
    });

    // 4. Schedule SMS with Twilio
    const reminderBody = `Hi ${name}. Just a reminder that you have an appointment scheduled at ${localAppointmentTimeString} (${timeZone}).`; // Use local time in message

    const message = await twilioClient.messages.create({
      to: phoneNumber,
      messagingServiceSid: messagingServiceSid, // Use Messaging Service SID!
      body: reminderBody,
      scheduleType: 'fixed',
      sendAt: reminderTimeUTC.toISOString(), // Send UTC time in ISO 8601 format
    });

    // 5. Update appointment record with Twilio Message SID (optional but good practice)
    await prisma.appointment.update({
        where: { id: appointment.id },
        data: { twilioMessageSid: message.sid },
    });

    // 6. Return Success Response
    console.log(`Scheduled message SID: ${message.sid} for appointment ID: ${appointment.id}`);
    res.status(201).json({
        message: "Appointment scheduled successfully!",
        appointmentId: appointment.id,
        messageSid: message.sid,
        scheduledSendTimeUTC: reminderTimeUTC.toISOString(),
    });

  } catch (error: any) {
    console.error('API Error:', error);

    // Handle potential Twilio API errors specifically
     if (error.response && error.response.data) {
        console.error('Twilio API Error Details:', error.response.data);
        return res.status(error.status || 500).json({ error: `Twilio Error: ${error.message}`, details: error.response.data });
     }

     // Handle potential Prisma errors (e.g., unique constraint)
     if (error.code && error.code.startsWith('P')) { // Prisma error codes start with P
        console.error('Prisma Error Details:', error);
        return res.status(409).json({ error: 'Database error', details: error.message }); // 409 Conflict might be appropriate
     }

     // General server error
     return res.status(500).json({ error: 'Internal Server Error', details: error.message });
   } finally {
     await prisma.$disconnect(); // Disconnect Prisma client
   }
}

Explanation:

  1. Imports: Necessary modules from Next.js, Prisma, Twilio, Zod, and date-fns.
  2. Prisma Client: Instantiated outside the handler for reuse.
  3. Zod Schema: Defines the expected shape and validation rules for the incoming request body.
  4. Twilio Client: Initialized using environment variables. Includes checks to ensure variables are present.
  5. Handler Function:
    • Checks for POST method.
    • Validates Twilio configuration.
    • Input Validation: Uses appointmentSchema.safeParse to validate req.body. Returns a 400 error if validation fails.
    • Time Zone Handling:
      • Parses the incoming localAppointmentTimeString using parseISO.
      • Converts the local time to UTC using zonedTimeToUtc from date-fns-tz, crucial for consistent storage and scheduling.
      • Calculates the reminderTimeUTC (1 hour before appointmentTimeUTC).
      • Crucially, checks if reminderTimeUTC is within Twilio's allowed window (more than 15 minutes from now, less than 35 days from now). Returns a 400 error if not.
    • Database Save: Creates a new appointment record in the database using prisma.appointment.create, storing the appointmentTime in UTC.
    • Twilio Scheduling:
      • Constructs the reminder message body.
      • Calls twilioClient.messages.create with:
        • to: The validated phone number.
        • messagingServiceSid: Mandatory for scheduling. Twilio uses numbers from this service's pool to send the message.
        • body: The reminder text.
        • scheduleType: 'fixed': Specifies a fixed time for sending.
        • sendAt: The calculated reminderTimeUTC converted to ISO 8601 format (required by Twilio).
    • Update Record: Updates the newly created appointment with the message.sid returned by Twilio. This allows tracking or canceling the scheduled message later if needed.
    • Response: Returns a 201 status code with the new appointment ID and Twilio message SID upon success.
    • Error Handling: Includes a try...catch block to handle validation, database, Twilio API, and other server errors, returning appropriate status codes and messages. Logs errors to the console.
    • Prisma Disconnect: Ensures the Prisma client connection is closed.

Testing the API Endpoint:

You can use curl or Postman to test this endpoint directly:

bash
curl -X POST http://localhost:3000/api/appointments \
-H "Content-Type: application/json" \
-d '{
  "name": "Jane Doe",
  "phoneNumber": "+15559876543",
  "appointmentTime": "2025-05-15T14:30:00",
  "timeZone": "America/New_York"
}'

Expected Success Response (JSON):

json
{
  "message": "Appointment scheduled successfully!",
  "appointmentId": "cl...", // Prisma-generated ID
  "messageSid": "SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", // Twilio Message SID
  "scheduledSendTimeUTC": "2025-05-15T17:30:00.000Z" // Example: 14:30 EDT is 18:30 UTC, reminder 1hr before is 17:30 UTC
}

Expected Error Response (e.g., Validation Error):

json
{
  "error": "Invalid input",
  "details": {
    "fieldErrors": {
      "phoneNumber": [
        "Invalid phone number format (must be E.164)"
      ]
    },
    "formErrors": []
  }
}

4. Integrating with Twilio (Messaging Service Setup)

Using Twilio's Message Scheduling requires a Messaging Service.

Steps to Create and Configure a Messaging Service:

  1. Navigate to Messaging Services: Go to the Twilio Console -> Messaging -> Services.
  2. Create Service: Click "Create Messaging Service".
  3. Friendly Name: Enter a name (e.g., "Appointment Reminders Service") and click "Create".
  4. Use Case: Select "Notify my users" (or another relevant option). Click "Continue".
  5. Add Senders: This is the crucial step.
    • Click "Add Senders".
    • Sender Type: Select "Phone Number". Click "Continue".
    • Select Numbers: Choose the Twilio phone number(s) you purchased and want to use for sending reminders. Check the box next to the number(s).
    • Click "Add Phone Numbers".
  6. Integration (Optional): Review settings for incoming messages if needed (not required for sending scheduled reminders). Click "Step 3: Set up integration", then "Step 4: Add compliance info".
  7. Compliance (Optional for initial testing): You may need to register for A2P 10DLC if sending to US numbers in production. For testing, you can often skip this initially, but be aware of filtering. Click "Complete Messaging Service Setup".
  8. Find Service SID: Go back to the Messaging Services page. Find the service you just created. The SID (starting with MG) is listed there.
  9. Update .env.local: Copy this MG... SID and paste it as the value for TWILIO_MESSAGING_SERVICE_SID in your .env.local file.

Why Messaging Service?

  • Required for Scheduling: Twilio's API mandates using a Messaging Service SID when scheduleType is set.
  • Scalability: Easily add/remove numbers, configure geo-matching, sticky sender, etc.
  • Compliance: Central point for managing compliance features like A2P 10DLC registration.

API Keys (.env.local):

Ensure your TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are correctly copied from the Twilio Console Dashboard into your .env.local file.

Message Scheduling Requirements and Constraints:

Per official Twilio documentation (2024-2025), the following requirements and limitations apply:

  • Scheduling Window: Messages must be scheduled between 15 minutes and 35 days in the future from the time Twilio receives the POST request.
  • Required Parameters:
    • ScheduleType: Must be set to "fixed" for scheduled messages.
    • SendAt: Timestamp in ISO 8601 format (e.g., 2025-05-15T17:30:00.000Z).
    • MessagingServiceSid: A 32-character hexadecimal ID starting with "MG". Mandatory for all scheduled messages.
    • To: Recipient phone number in E.164 format.
    • Message content: One of Body (text up to 1,600 characters), MediaUrl, or ContentSid.
  • Account Limits: Maximum of 1,000,000 scheduled messages per Account or Subaccount at any given time.
  • Supported Channels: SMS, MMS, RCS, and WhatsApp messages can be scheduled. WhatsApp requires an approved sender in the Messaging Service Sender Pool and pre-approved message templates.
  • Pricing: Message scheduling itself is free. You pay standard message sending rates only when messages are actually sent at the scheduled time. Engagement Suite features (if used) cost $0.015 per message after the first 1,000 free monthly uses (as of February 2024).
  • Opt-Out Handling: Scheduled messages to opted-out users will fail at send time, not at scheduling time.
  • Status Code: Successful scheduling returns HTTP 201 status with the message SID. Invalid requests return HTTP 400.

5. Error Handling, Logging, and Retries

Error Handling:

  • API Route (appointments.ts):
    • Uses Zod for robust input validation, returning 400 errors with details.
    • Wraps core logic in try...catch.
    • Catches specific Twilio errors (checking error.response, error.status, error.message).
    • Catches specific Prisma errors (checking error.code).
    • Provides a generic 500 error for unexpected issues.
    • Returns informative JSON error messages.

Logging:

  • Current: Uses console.log for successes and console.error for failures within the API route. This is suitable for development and basic Vercel logging.
  • Production: Consider integrating a dedicated logging service (e.g., Logtail, Datadog, Sentry via Vercel Integrations). These offer structured logging, search, alerting, and retention.
  • Twilio Logs: Use the Twilio Console Logs (especially the ""Scheduled"" tab) to monitor scheduled message status, attempts, and errors directly within Twilio.

Retry Mechanisms:

  • Twilio Handles Scheduling Retries: Once a message is successfully scheduled via the API (you get a message.sid), Twilio manages the queue and any necessary retries for sending the message at the scheduled time based on carrier availability and deliverability factors. You don't need to implement backend retries for the sending part itself.
  • API Call Retries: If the initial API call to twilioClient.messages.create fails (e.g., network issue, temporary Twilio outage), you could implement a retry strategy on your server (e.g., using async-retry package with exponential backoff). However, for this specific use case, it might be simpler to return an error to the user and let them try submitting the form again. Adding server-side retries for the scheduling call adds complexity (e.g., ensuring idempotency).

Testing Error Scenarios:

  • Submit the form with invalid data (bad phone number, missing fields, past date).
  • Temporarily invalidate TWILIO_AUTH_TOKEN in .env.local to test Twilio auth errors.
  • Try scheduling a reminder less than 15 minutes or more than 35 days in the future.
  • Simulate database errors (e.g., shut down your local DB).

6. Database Schema and Data Layer

Prisma Schema (prisma/schema.prisma):

Define the Appointment model.

prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Appointment {
  id        String   @id @default(cuid()) // Unique ID for the appointment
  name      String
  phoneNumber String   // E.164 format
  appointmentTime DateTime // Stored in UTC
  timeZone  String   // Original time zone submitted by user (e.g., "America/New_York")

  status          String?  @default("SCHEDULED") // e.g., SCHEDULED, REMINDER_SENT, CANCELED
  twilioMessageSid String?  @unique // The SID of the scheduled Twilio message, unique constraint helps prevent duplicate schedules for the same message.

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([appointmentTime]) // Index for querying by time
}

Explanation:

  • id: Unique identifier (CUID).
  • name, phoneNumber: Customer details.
  • appointmentTime: The actual time of the appointment, stored as a DateTime in UTC.
  • timeZone: The user's original time zone, useful for display or context.
  • status: Tracks the appointment/reminder state.
  • twilioMessageSid: Stores the SID returned by Twilio when scheduling the message. Making it @unique can help prevent accidentally scheduling multiple reminders if your API logic had flaws (though the primary check should be in the application logic).
  • createdAt, updatedAt: Standard timestamps.
  • @@index([appointmentTime]): Adds a database index to efficiently query appointments based on their time.

Migrations:

  1. Create Migration: After defining or modifying your schema, create a migration file:

    bash
    npx prisma migrate dev --name init_appointments
    • This command generates SQL migration files in prisma/migrations/ and applies the changes to your development database. --name provides a descriptive label for the migration.
  2. Apply Migrations (Production): In a production environment, you typically run:

    bash
    npx prisma migrate deploy

    This applies all pending migrations found in the prisma/migrations folder.

Data Layer:

  • The data layer logic is handled within the API route (pages/api/appointments.ts) using the Prisma Client (prisma.appointment.create, prisma.appointment.update).
  • Prisma Client provides type-safe database access based on your schema.

Sample Data (Optional):

You could create a separate script (prisma/seed.ts) to populate sample data if needed for testing, using prisma.$connect() and prisma.appointment.createMany(...), then run it with npx prisma db seed.

7. Security Features

  • Input Validation: Zod (appointmentSchema) in the API route provides robust validation against the expected data types, formats (basic E.164), and presence of required fields. This prevents malformed data from reaching your database or Twilio.
  • Environment Variables: API keys and database URLs are stored in .env.local, which is not committed to Git, preventing accidental exposure. Ensure these are set securely in your deployment environment.
  • API Route Protection: The API route only accepts POST requests. Other methods are rejected.
  • Rate Limiting (Recommended): Protect your API endpoint from abuse.
    • Vercel: Vercel provides built-in rate limiting features on Hobby (limited) and Pro plans. You can configure limits per IP or user token.
    • Middleware: Implement rate limiting using Next.js Middleware and libraries like upstash/ratelimit or rate-limiter-flexible.
  • Cross-Site Scripting (XSS): React inherently escapes data rendered in JSX, providing basic protection. Avoid using dangerouslySetInnerHTML. Ensure any user-provided content displayed back is properly sanitized if not handled by React's escaping.
  • SQL Injection: Prisma uses parameterized queries by default, preventing SQL injection vulnerabilities.
  • Phone Number Validation: The current E.164 regex is basic. For production, consider using a more comprehensive library like google-libphonenumber (via its Node.js port) for stricter validation, although this adds complexity. Twilio's Lookup API can also validate numbers but incurs cost.

Testing Security:

  • Attempt to POST malformed JSON to the API endpoint.
  • Try sending invalid phone numbers or time zones.
  • Use security scanning tools (like OWASP ZAP locally, or integrated scanners in CI/CD) to check for common vulnerabilities.
  • Review Vercel's security settings and logs for suspicious activity.

8. Handling Special Cases

  • Time Zones (Covered in API):
    • Collect time zone input from the user.
    • Convert the user's local appointment time + time zone to UTC using date-fns-tz.
    • Store the appointment time in UTC in the database.

Frequently Asked Questions

How do I schedule SMS messages with Twilio in Next.js?

Use Twilio's Message Scheduling feature by calling twilioClient.messages.create() with three required parameters: messagingServiceSid (starting with "MG"), scheduleType: "fixed", and sendAt (ISO 8601 timestamp). You must create a Twilio Messaging Service and add your phone number to its sender pool before scheduling messages. Messages can be scheduled between 15 minutes and 35 days in the future.

What is the scheduling window for Twilio Message Scheduling?

Twilio requires messages to be scheduled between 15 minutes and 35 days in the future from when the API receives the POST request. Attempting to schedule outside this window returns an HTTP 400 error. This constraint is documented in official Twilio Message Scheduling documentation (2024–2025).

Do I need a Twilio Messaging Service to schedule SMS?

Yes. Twilio's Message Scheduling feature requires a Messaging Service SID (starting with "MG"). You cannot schedule messages using only a phone number. Create a Messaging Service in the Twilio Console under Messaging → Services, add your Twilio phone number to the sender pool, and use the service SID in your API calls.

How much does Twilio Message Scheduling cost?

Message scheduling itself is free. You pay standard Twilio message rates only when messages are actually sent at the scheduled time. For example, if you schedule 1,000 SMS messages, you're charged standard SMS rates when they're delivered, not when you schedule them. Engagement Suite features (link shortening, click tracking) cost $0.015 per message after the first 1,000 free monthly uses (as of February 2024).

How do I handle time zones for appointment reminders?

Use the date-fns-tz library to convert user-provided local times to UTC before storing in your database and scheduling with Twilio. Collect the user's time zone (e.g., "America/New_York"), combine it with their appointment date/time, convert to UTC using zonedTimeToUtc(), then calculate the reminder time (e.g., 1 hour before). Always store appointment times in UTC and pass UTC timestamps to Twilio's sendAt parameter in ISO 8601 format.

Can I cancel or update scheduled SMS messages?

Yes. Use the Twilio API to cancel scheduled messages before they're sent by calling messages(messageSid).update({ status: 'canceled' }). Store the message.sid returned when scheduling (as shown in this guide's Prisma schema with twilioMessageSid field) to enable cancellation later. You cannot modify message content after scheduling – you must cancel and reschedule with new content.

What Node.js and Next.js versions should I use?

Use Node.js v22.x LTS (Active LTS through April 2027) or v20.x LTS (Maintenance through April 2026) for production. Next.js v15.2+ is current stable (February 2025) with React 19 support and Turbopack improvements. This guide uses Pages Router for clarity, but App Router (stable since Next.js 13.4) is recommended for new projects. Prisma ORM v6.16.0+ provides optimal PostgreSQL performance with the Rust-free architecture preview.

Why are my scheduled messages failing?

Common causes: (1) Missing or invalid TWILIO_MESSAGING_SERVICE_SID environment variable – verify it starts with "MG" and is correctly configured, (2) Scheduling outside the 15-minute to 35-day window – check your reminder time calculation logic, (3) Phone number not in E.164 format – ensure numbers include country code like +15551234567, (4) Messages to opted-out users fail at send time, not scheduling time – check Twilio Console logs for opt-out errors, (5) Invalid Twilio credentials – verify TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in .env.local.

Frequently Asked Questions

How to schedule SMS reminders with Twilio?

Use Twilio's Message Scheduling feature with a Messaging Service. Create an appointment, specify the reminder time, and Twilio handles the sending. This eliminates the need for custom cron jobs or background workers on your backend.

What is Twilio Message Scheduling used for?

Twilio Message Scheduling allows you to schedule SMS/MMS messages for future delivery. This is ideal for appointment reminders, notifications, or any time-sensitive communication that needs to be sent automatically at a specific time.

Why does Twilio require a Messaging Service for scheduling?

A Messaging Service is mandatory for using Twilio's Message Scheduling. It manages the sending process, including selecting the appropriate number from your pool, and ensures compliance requirements are met. This simplifies scalability and number management.

When should I use date-fns-tz for time zone conversion?

Use date-fns-tz when working with user-provided time zones and converting local times to UTC for storage and scheduling. This library handles various time zones accurately and ensures consistency in scheduling.

Can I schedule messages less than 15 minutes in the future with Twilio?

No, Twilio's scheduling has a minimum 15-minute lead time. If you try to schedule a message sooner, the API will return an error. Reminders must be scheduled at least 15 minutes from now, and within 7 days.

What technologies are used in this appointment reminder app?

The app uses Next.js and Node.js for the core framework, Twilio Programmable Messaging for SMS, Prisma ORM with PostgreSQL for data, Zod for validation, and date-fns/date-fns-tz for time zone handling.

How to create a Next.js API route for appointments?

Create a file in the 'pages/api' directory (e.g., 'appointments.ts'). Inside, export a default asynchronous function that handles the request and response. This function will contain your API logic to process and schedule appointments.

What is Prisma used for in this project?

Prisma is an Object-Relational Mapper (ORM) used for database interaction. It simplifies database operations, provides type safety, and manages migrations, making it easier to work with PostgreSQL in the project.

Why is Zod important in the Next.js API route?

Zod provides data validation, ensuring data integrity and preventing errors. It validates the incoming requests against a predefined schema to ensure all required data is present and is in the correct format.

How to handle time zones in appointment scheduling?

Collect the user's time zone along with appointment details. Convert the local appointment time to UTC using a library like date-fns-tz. Store appointment times in UTC in your database, then convert back to local time when needed for display or reminders.

How to handle Twilio API errors in the Next.js app?

Wrap your Twilio API calls in a try...catch block. Check for 'error.response' and 'error.status' to identify specific Twilio errors. Return appropriate error messages to the user, and log details for debugging.

What database is used and how is it set up?

PostgreSQL is used. Initialize Prisma with PostgreSQL using 'npx prisma init --datasource-provider postgresql'. Configure the 'DATABASE_URL' in '.env.local' with your database credentials.

How to secure API keys and database URL in a Next.js application?

Store sensitive information like API keys and the database URL in '.env.local'. This file is automatically ignored by Git, preventing accidental exposure.

How to set up a Twilio Messaging Service for scheduling?

In the Twilio Console, go to Messaging > Services > Create Messaging Service. Add your Twilio phone number as a sender to the service. Then, obtain the Messaging Service SID (starting with 'MG') to use in your app.