code examples
code examples
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:
- Schedule future appointments, specifying details like name, phone number, appointment date/time, and time zone.
- 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:
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.
-
Create Next.js App: Open your terminal and run:
bashnpx create-next-app@latest --typescript appointment-reminders-nextjs cd appointment-reminders-nextjs -
Install Dependencies: Install Prisma for database interactions, the Twilio helper library, and Zod for validation.
bashnpm install prisma @prisma/client twilio zod date-fns date-fns-tz npm install --save-dev @types/node typescript ts-node @types/react @types/react-domprisma: 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/clientv6.x for type-safe database access. - Twilio: Latest Node.js helper library (
twilionpm package) fully supports Message Scheduling withscheduleTypeandsendAtparameters. - 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-tzfor time zone support.
-
Initialize Prisma: Set up Prisma with PostgreSQL as the provider.
bashnpx prisma init --datasource-provider postgresqlThis creates a
prismadirectory with aschema.prismafile and a.envfile for your database connection string. -
Configure Environment Variables: Prisma added
DATABASE_URLto.env. Add Twilio variables as well. Rename.envto.env.local(which Next.js uses and ignores in Git by default)..env.localdotenv# 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 (remindersin 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 withMG) on the Messaging Services page.
-
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
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,mindate) 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
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:
- Imports: Necessary modules from Next.js, Prisma, Twilio, Zod, and date-fns.
- Prisma Client: Instantiated outside the handler for reuse.
- Zod Schema: Defines the expected shape and validation rules for the incoming request body.
- Twilio Client: Initialized using environment variables. Includes checks to ensure variables are present.
- Handler Function:
- Checks for POST method.
- Validates Twilio configuration.
- Input Validation: Uses
appointmentSchema.safeParseto validatereq.body. Returns a 400 error if validation fails. - Time Zone Handling:
- Parses the incoming
localAppointmentTimeStringusingparseISO. - Converts the local time to UTC using
zonedTimeToUtcfromdate-fns-tz, crucial for consistent storage and scheduling. - Calculates the
reminderTimeUTC(1 hour beforeappointmentTimeUTC). - Crucially, checks if
reminderTimeUTCis within Twilio's allowed window (more than 15 minutes from now, less than 35 days from now). Returns a 400 error if not.
- Parses the incoming
- Database Save: Creates a new appointment record in the database using
prisma.appointment.create, storing theappointmentTimein UTC. - Twilio Scheduling:
- Constructs the reminder message body.
- Calls
twilioClient.messages.createwith: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 calculatedreminderTimeUTCconverted to ISO 8601 format (required by Twilio).
- Update Record: Updates the newly created appointment with the
message.sidreturned 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...catchblock 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:
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):
{
"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):
{
"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:
- Navigate to Messaging Services: Go to the Twilio Console -> Messaging -> Services.
- Create Service: Click "Create Messaging Service".
- Friendly Name: Enter a name (e.g., "Appointment Reminders Service") and click "Create".
- Use Case: Select "Notify my users" (or another relevant option). Click "Continue".
- 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".
- 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".
- 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".
- Find Service SID: Go back to the Messaging Services page. Find the service you just created. The SID (starting with
MG) is listed there. - Update
.env.local: Copy thisMG...SID and paste it as the value forTWILIO_MESSAGING_SERVICE_SIDin your.env.localfile.
Why Messaging Service?
- Required for Scheduling: Twilio's API mandates using a Messaging Service SID when
scheduleTypeis 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, orContentSid.
- 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.logfor successes andconsole.errorfor 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.createfails (e.g., network issue, temporary Twilio outage), you could implement a retry strategy on your server (e.g., usingasync-retrypackage 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_TOKENin.env.localto 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.
// 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 aDateTimein 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@uniquecan 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:
-
Create Migration: After defining or modifying your schema, create a migration file:
bashnpx prisma migrate dev --name init_appointments- This command generates SQL migration files in
prisma/migrations/and applies the changes to your development database.--nameprovides a descriptive label for the migration.
- This command generates SQL migration files in
-
Apply Migrations (Production): In a production environment, you typically run:
bashnpx prisma migrate deployThis applies all pending migrations found in the
prisma/migrationsfolder.
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/ratelimitorrate-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.