code examples
code examples
Build Scheduled SMS Reminders with Next.js and Plivo
A guide detailing how to build a production-ready application using Next.js and Plivo to schedule and automatically send SMS reminders, covering setup, implementation, deployment, and monitoring.
Build scheduled SMS reminders with Next.js and Plivo
This guide details how to build a production-ready application using Next.js and Plivo to schedule and automatically send SMS reminders. We'll cover everything from project setup and core logic implementation to deployment and monitoring.
The final application will enable users (or an admin) to define a phone number, a message, and a future date/time. At the scheduled time, the system will automatically send the specified message to the target phone number via SMS using the Plivo API. This is ideal for appointment reminders, event notifications, follow-ups, and more.
Project overview and goals
Goal: Create a reliable system for scheduling and sending SMS messages at specific future times.
Problem Solved: Automates the process of sending timely reminders, reducing no-shows for appointments, ensuring users receive critical information on time, and freeing up manual effort.
Technologies:
- Next.js (App Router): A React framework for building full-stack web applications. Chosen for its robust features, server components, API routes, and excellent developer experience.
- Plivo: A cloud communications platform providing SMS APIs. Chosen for its reliable SMS delivery, developer-friendly APIs, and clear pricing.
- Prisma: A next-generation Node.js and TypeScript ORM. Used for database interaction, schema management, and type safety.
- Vercel Postgres: A serverless SQL database. Chosen for its ease of integration with Vercel and Next.js.
- Vercel Cron Jobs: A serverless scheduler for running jobs reliably. Used to trigger the check for and sending of due reminders.
- Tailwind CSS: A utility-first CSS framework for styling the minimal UI.
- TypeScript: For static typing and improved code quality.
System Architecture:
(Note: The following diagram uses Mermaid syntax. Ensure your publishing platform supports Mermaid rendering, or replace this with a static image alternative.)
graph TD
A[User/Client Browser] -- 1. Schedule Request (Form Submit) --> B(Next.js App / API Route);
B -- 2. Save Schedule --> C[(Vercel Postgres via Prisma)];
D{Vercel Cron Job} -- 3. Trigger Check (e.g., every minute) --> E(Next.js Cron Handler API Route);
E -- 4. Query Due Schedules --> C;
E -- 5. Send SMS Request --> F(Plivo SMS API);
F -- 6. Send SMS --> G([End User Phone]);
E -- 7. Update Schedule Status --> C;Prerequisites:
- Node.js (v18 or later recommended)
- npm, yarn, or pnpm package manager
- A Plivo account with API credentials and a Plivo phone number
- A Vercel account (for Vercel Postgres and Cron Jobs deployment)
- Git and a GitHub account (or similar Git provider)
Final Outcome: A deployed Next.js application capable of accepting SMS scheduling requests via a simple UI or API, storing them, and reliably sending them at the scheduled time using Plivo and Vercel Cron Jobs.
1. Setting up the project
Let's initialize our Next.js project and install the necessary dependencies.
-
Create Next.js App: Open your terminal and run the following command, choosing options like TypeScript, Tailwind CSS, App Router, etc., when prompted.
bashnpx create-next-app@latest plivo-scheduler-app cd plivo-scheduler-app -
Install Dependencies: Add Plivo, Prisma, and date-fns for date manipulation.
bashnpm install plivo @prisma/client date-fns date-fns-tz zod npm install --save-dev prismaplivo: Plivo Node.js SDK.@prisma/client: Prisma database client.date-fns&date-fns-tz: Robust date/time handling and time zone support.zod: Schema declaration and validation library.prisma(dev): Prisma CLI for migrations and studio.
-
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: Open the
.envfile (renamed automatically fromprisma/.envto the project root bycreate-next-app, or create.env.localif preferred). Add placeholders for Plivo credentials and the database URL. We'll get the actual values later.dotenv# .env.local (or .env) # Database (Vercel Postgres will provide this) POSTGRES_PRISMA_URL=""postgresql://user:password@host:port/database?sslmode=require"" POSTGRES_URL_NON_POOLING=""postgresql://user:password@host:port/database?sslmode=require"" # For migrations # Plivo Credentials PLIVO_AUTH_ID=""YOUR_PLIVO_AUTH_ID"" PLIVO_AUTH_TOKEN=""YOUR_PLIVO_AUTH_TOKEN"" PLIVO_SENDER_NUMBER=""YOUR_PLIVO_PHONE_NUMBER"" # Must be in E.164 format, e.g., +14155552671 # Cron Job Security (Optional but Recommended) CRON_SECRET=""YOUR_SECURE_RANDOM_STRING""- Security:
.env.localis ignored by Git by default, keeping your secrets safe. Never commit files containing sensitive credentials. POSTGRES_PRISMA_URLvsPOSTGRES_URL_NON_POOLING: Vercel Postgres provides two URLs. The Prisma client uses the pooled connection (POSTGRES_PRISMA_URL). Prisma Migrate needs the non-pooled version (POSTGRES_URL_NON_POOLING). Ensure both are set, especially when deploying.CRON_SECRET: A secret string you generate to verify that requests to your cron handler endpoint genuinely come from Vercel Cron Jobs.
- Security:
-
Project Structure: Your basic structure within the
appdirectory will look like this:app/page.tsx: Frontend UI for scheduling.app/api/schedule/route.ts: API endpoint to handle schedule creation.app/api/cron/route.ts: API endpoint triggered by Vercel Cron to send due messages.lib/: Utility functions (Prisma client, Plivo client).prisma/schema.prisma: Database schema definition.
2. Implementing core functionality
We'll now build the key parts: the UI form, the API to save schedules, the Plivo integration, and the cron job logic.
2.1 Database Schema (prisma/schema.prisma)
Define the model for storing scheduled SMS messages.
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql""
url = env(""POSTGRES_PRISMA_URL"") // Uses connection pooling
directUrl = env(""POSTGRES_URL_NON_POOLING"") // Used for migrations
}
model Schedule {
id String @id @default(cuid()) // Unique identifier
phoneNumber String // Recipient phone number (E.164 format)
message String // SMS message content
sendAt DateTime // Scheduled time in UTC
status String @default(""PENDING"") // PENDING, SENT, FAILED
plivoMessageId String? // Store Plivo's message UUID after sending
error String? // Store error message if sending fails
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([sendAt, status]) // Index for efficient querying by the cron job
}sendAt: Stores the scheduled time in UTC to avoid time zone ambiguity.status: Tracks the state of the scheduled message.plivoMessageId: Useful for tracking the message status within Plivo if needed.error: Logs any issues during sending.@@index: Crucial for performance. The cron job frequently queries forPENDINGschedules around the current time.
2.2 Prisma Client Setup (lib/prisma.ts)
Create a reusable Prisma client instance.
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}- This pattern prevents creating multiple Prisma Client instances during development hot-reloading.
- Logging is more verbose in development.
2.3 Plivo Client Setup (lib/plivo.ts)
Initialize the Plivo client and create a helper function for sending SMS.
// lib/plivo.ts
import * as plivo from 'plivo';
const authId = process.env.PLIVO_AUTH_ID;
const authToken = process.env.PLIVO_AUTH_TOKEN;
const senderNumber = process.env.PLIVO_SENDER_NUMBER;
if (!authId || !authToken || !senderNumber) {
throw new Error(""Plivo credentials (AUTH_ID, AUTH_TOKEN, SENDER_NUMBER) are not set in environment variables."");
}
const client = new plivo.Client(authId, authToken);
interface SendSmsResult {
success: boolean;
messageUuid?: string;
error?: string;
}
export async function sendPlivoSms(to: string, body: string): Promise<SendSmsResult> {
console.log(`Attempting to send SMS via Plivo to: ${to}`);
try {
const response = await client.messages.create({
src: senderNumber as string, // Ensure it's treated as string
dst: to,
text: body,
// Optional: Add a URL for delivery status callbacks
// url: 'https://<yourdomain>.com/api/plivo-status',
// method: 'POST'
});
console.log(`Plivo API Response:`, response);
// The Plivo SDK returns messageUuid as an array, even for single messages.
if (response.messageUuid && response.messageUuid.length > 0) {
const firstUuid = response.messageUuid[0];
console.log(`SMS submitted successfully to Plivo. Message UUID: ${firstUuid}`);
return { success: true, messageUuid: firstUuid };
} else {
// Plivo's API might return a 2xx status but indicate an issue in the body
const errorMessage = response.error || 'Unknown error from Plivo (No message UUID)';
console.error(`Plivo sending failed (API level): ${errorMessage}`);
return { success: false, error: errorMessage };
}
} catch (error: any) {
const errorMessage = error.message || 'Failed to send SMS via Plivo';
console.error(`Plivo sending failed (SDK/Network level): ${errorMessage}`, error);
return { success: false, error: errorMessage };
}
}- Error Handling: Checks for environment variables and wraps the API call in a
try...catch. - Result Object: Returns a structured object indicating success/failure and including the
messageUuidor error message. - Logging: Includes logs for attempting and results of sending.
- Type Safety: Uses TypeScript interfaces.
- Message UUID: The code accesses
response.messageUuid[0]because the Plivo Node.js SDK returns the UUID(s) in an array.
2.4 Scheduling UI (app/page.tsx)
A simple form to create new schedules.
// app/page.tsx
""use client""; // This component uses client-side interactivity (useState, fetch)
import { useState, FormEvent } from 'react';
import { format, parse, isValid } from 'date-fns';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
export default function HomePage() {
const [phoneNumber, setPhoneNumber] = useState('');
const [message, setMessage] = useState('');
const [scheduleDate, setScheduleDate] = useState(''); // YYYY-MM-DD
const [scheduleTime, setScheduleTime] = useState(''); // HH:MM (24-hour)
const [timeZone, setTimeZone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone); // Detect user's timezone
const [status, setStatus] = useState(''); // For user feedback
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsLoading(true);
setStatus('');
// Basic validation
if (!phoneNumber || !message || !scheduleDate || !scheduleTime) {
setStatus('Error: Please fill in all fields.');
setIsLoading(false);
return;
}
// Combine date and time, parse it in the user's selected timezone
const dateTimeString = `${scheduleDate}T${scheduleTime}:00`;
let localDate: Date;
try {
// Ensure parsing considers the correct format explicitly
localDate = parse(dateTimeString, ""yyyy-MM-dd'T'HH:mm:ss"", new Date());
if (!isValid(localDate)) {
throw new Error(""Invalid date/time format"");
}
} catch (err) {
setStatus(`Error: Invalid date or time format. Use YYYY-MM-DD and HH:MM.`);
setIsLoading(false);
return;
}
// Convert the user's local time to UTC for storage
const scheduledAtUtc: Date = zonedTimeToUtc(localDate, timeZone);
console.log(""Local Input:"", localDate, ""TimeZone:"", timeZone, ""Calculated UTC:"", scheduledAtUtc);
// Ensure date is in the future
if (scheduledAtUtc <= new Date()) {
setStatus('Error: Scheduled time must be in the future.');
setIsLoading(false);
return;
}
try {
const response = await fetch('/api/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phoneNumber,
message,
// Send the UTC date string in ISO format
scheduledAt: scheduledAtUtc.toISOString(),
}),
});
const result = await response.json();
if (response.ok) {
setStatus(`Success! Schedule created with ID: ${result.id}`);
// Clear form on success
setPhoneNumber('');
setMessage('');
setScheduleDate('');
setScheduleTime('');
} else {
setStatus(`Error: ${result.error || 'Failed to create schedule.'}`);
}
} catch (error) {
console.error(""Scheduling failed:"", error);
setStatus('Error: An unexpected error occurred.');
} finally {
setIsLoading(false);
}
};
// Simple Timezone Selector.
// NOTE: This is a basic implementation suitable for this guide.
// For a production app, consider a more robust library or dynamic list.
const renderTimezoneSelector = () => (
<select
value={timeZone}
onChange={(e) => setTimeZone(e.target.value)}
// Added text-black for explicit visibility on light backgrounds.
// Be mindful this might conflict if implementing a dark mode theme.
className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black""
>
{/* Add more timezones as needed */}
<option value=""America/New_York"">America/New_York (ET)</option>
<option value=""America/Chicago"">America/Chicago (CT)</option>
<option value=""America/Denver"">America/Denver (MT)</option>
<option value=""America/Los_Angeles"">America/Los_Angeles (PT)</option>
<option value=""Europe/London"">Europe/London (GMT/BST)</option>
<option value=""UTC"">UTC</option>
{/* Add the user's detected timezone if it's not already in the list */}
{Intl.DateTimeFormat().resolvedOptions().timeZone !== 'UTC' && !['America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London'].includes(Intl.DateTimeFormat().resolvedOptions().timeZone) && (
<option value={Intl.DateTimeFormat().resolvedOptions().timeZone}>{Intl.DateTimeFormat().resolvedOptions().timeZone} (Detected)</option>
)}
</select>
);
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 p-8 space-y-6 bg-white rounded-lg shadow-md"">
<h1 className=""text-2xl font-bold text-center text-gray-900"">Schedule SMS Reminder</h1>
<form onSubmit={handleSubmit} className=""space-y-4"">
<div>
<label htmlFor=""phoneNumber"" className=""block text-sm font-medium text-gray-700"">Phone Number (E.164 format)</label>
<input
id=""phoneNumber""
name=""phoneNumber""
type=""tel""
required
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder=""+14155552671""
// Added text-black for explicit visibility on light backgrounds.
// Be mindful this might conflict if implementing a dark mode theme.
className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black""
/>
</div>
<div>
<label htmlFor=""message"" className=""block text-sm font-medium text-gray-700"">Message</label>
<textarea
id=""message""
name=""message""
rows={3}
required
value={message}
onChange={(e) => setMessage(e.target.value)}
// Added text-black for explicit visibility on light backgrounds.
// Be mindful this might conflict if implementing a dark mode theme.
className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black""
/>
</div>
<div className=""grid grid-cols-2 gap-4"">
<div>
<label htmlFor=""scheduleDate"" className=""block text-sm font-medium text-gray-700"">Date</label>
<input
id=""scheduleDate""
name=""scheduleDate""
type=""date"" // HTML5 date input
required
value={scheduleDate}
onChange={(e) => setScheduleDate(e.target.value)}
// Added text-black for explicit visibility on light backgrounds.
// Be mindful this might conflict if implementing a dark mode theme.
className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black""
/>
</div>
<div>
<label htmlFor=""scheduleTime"" className=""block text-sm font-medium text-gray-700"">Time</label>
<input
id=""scheduleTime""
name=""scheduleTime""
type=""time"" // HTML5 time input
required
value={scheduleTime}
onChange={(e) => setScheduleTime(e.target.value)}
// Added text-black for explicit visibility on light backgrounds.
// Be mindful this might conflict if implementing a dark mode theme.
className=""mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm text-black""
/>
</div>
</div>
<div>
<label htmlFor=""timeZone"" className=""block text-sm font-medium text-gray-700"">Time Zone</label>
{renderTimezoneSelector()}
</div>
<div>
<button
type=""submit""
disabled={isLoading}
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white ${
isLoading ? 'bg-indigo-400' : 'bg-indigo-600 hover:bg-indigo-700'
} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50`}
>
{isLoading ? 'Scheduling...' : 'Schedule Reminder'}
</button>
</div>
{status && (
<p className={`text-sm ${status.startsWith('Error:') ? 'text-red-600' : 'text-green-600'}`}>
{status}
</p>
)}
</form>
</div>
</main>
);
}""use client"";: Necessary for using React hooks (useState) and event handlers.- State Management: Uses
useStatefor form inputs, loading state, and status messages. - Time Zone Handling:
- Detects the user's local time zone using
Intl.DateTimeFormat. - Provides a basic dropdown to select the time zone (acknowledged as basic).
- Uses
date-fns-tz'szonedTimeToUtcto convert the user's input date/time (parsed in their selected time zone) into a UTCDateobject before sending it to the backend API.
- Detects the user's local time zone using
- API Call: Uses
fetchto POST the schedule data to/api/schedule. - Feedback: Displays success or error messages to the user.
- Basic Styling: Uses Tailwind CSS classes. Added explicit
text-blackto inputs for visibility on light backgrounds; this might need adjustment for dark mode support.
3. Building the API layer
We need an API endpoint to receive the schedule requests from the frontend (or other clients).
3.1 Schedule Creation API (app/api/schedule/route.ts)
This Next.js Route Handler validates the incoming request and saves the schedule to the database.
// app/api/schedule/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; // Adjust path as needed
import { z } from 'zod';
import { isValid, parseISO } from 'date-fns';
// Define input schema using Zod for validation
const scheduleSchema = z.object({
// Basic E.164 format validation (starts with +, followed by digits).
// NOTE: This regex is basic. For robust validation, consider using a library
// like `libphonenumber-js` (mentioned in Section 8).
phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/, { message: ""Invalid phone number format (must be E.164, e.g., +14155552671)""}),
message: z.string().min(1, { message: ""Message cannot be empty"" }).max(1600, { message: ""Message too long"" }), // Plivo limit is higher, but good practice
scheduledAt: z.string().refine((val) => {
const date = parseISO(val);
return isValid(date) && date > new Date();
}, { message: ""Invalid scheduled date or date is not in the future""}),
});
export async function POST(request: Request) {
try {
const body = await request.json();
// Validate input
const validation = scheduleSchema.safeParse(body);
if (!validation.success) {
console.error(""Schedule validation failed:"", validation.error.errors);
// Return detailed validation errors
return NextResponse.json({ error: validation.error.flatten().fieldErrors }, { status: 400 });
}
const { phoneNumber, message, scheduledAt } = validation.data;
// Convert ISO string back to Date object for Prisma
const scheduledAtDate = parseISO(scheduledAt);
// Save to database
const newSchedule = await prisma.schedule.create({
data: {
phoneNumber,
message,
sendAt: scheduledAtDate, // Store as DateTime (UTC)
status: 'PENDING',
},
});
console.log(""New schedule created:"", newSchedule);
// Return success response with the created schedule ID
return NextResponse.json({ id: newSchedule.id, message: ""Schedule created successfully"" }, { status: 201 });
} catch (error: any) {
console.error(""Error creating schedule:"", error);
// Handle potential Prisma errors or other exceptions
if (error instanceof z.ZodError) {
// Should be caught by safeParse, but as a fallback
return NextResponse.json({ error: ""Invalid input data"" }, { status: 400 });
}
return NextResponse.json({ error: ""Internal Server Error"", details: error.message }, { status: 500 });
}
}
// Optional: Add GET handler if you need to list schedules, etc.
// export async function GET(request: Request) { ... }- Route Handler: Uses the standard Next.js App Router
route.tspattern. - Input Validation: Leverages
zodto define a schema (scheduleSchema) and validate the request body. This ensures the phone number format is basic E.164, the message isn't empty, andscheduledAtis a valid ISO date string representing a future time. Returns specific error messages on failure. A note clarifies the basic nature of the regex and suggestslibphonenumber-jsfor better validation. - Database Interaction: Uses the imported
prismaclient tocreatea new schedule record. - UTC Handling: Assumes the
scheduledAtstring received from the client is already in UTC (ISO 8601 format), as prepared by the frontend. It parses this string into aDateobject, which Prisma handles correctly as a UTC timestamp in the database. - Error Handling: Includes a
try...catchblock to handle potential errors during JSON parsing, validation, or database operations, returning appropriate HTTP status codes. - Response: Returns a
201 Createdstatus with the ID of the newly created schedule on success, or error details on failure.
3.2 Testing the API Endpoint
You can test this endpoint using curl or a tool like Postman/Insomnia after running your Next.js development server (npm run dev).
Curl Example:
# !! IMPORTANT !!
# You MUST replace the value of FUTURE_UTC_DATE with a date/time
# in the future, formatted as YYYY-MM-DDTHH:MM:SSZ (UTC).
# Example: If current UTC time is 2025-04-20T10:00:00Z, use a time *after* that.
FUTURE_UTC_DATE=""2025-04-20T11:00:00Z"" # <-- CHANGE THIS TO A FUTURE UTC TIME
curl -X POST http://localhost:3000/api/schedule \
-H ""Content-Type: application/json"" \
-d '{
""phoneNumber"": ""+14155552671"",
""message"": ""Your test reminder from curl!"",
""scheduledAt"": ""'""$FUTURE_UTC_DATE""'""
}'
# Expected Success Response (Status 201):
# {""id"":""cl..."",""message"":""Schedule created successfully""}
# Example Validation Error (Status 400):
# {""error"":{""phoneNumber"":[""Invalid phone number format (must be E.164, e.g., +14155552671)""]}}- Note: Added strong emphasis regarding the need to change the hardcoded future date in the
curlcommand.
4. Integrating with third-party services
4.1 Plivo Setup
-
Sign Up: Create an account at Plivo.com.
-
Get Credentials:
- Navigate to your Plivo Console dashboard.
- On the overview page, you'll find your Auth ID and Auth Token.
- Copy these values.
-
Buy a Phone Number:
- Go to
Phone Numbers->Buy Numbersin the Plivo console. - Search for a number with SMS capabilities in your desired region (e.g., USA).
- Purchase the number.
- Note the number in E.164 format (e.g.,
+12025551234).
- Go to
-
Update Environment Variables: Paste your Auth ID, Auth Token, and the purchased phone number into your
.env.localfile.dotenv# .env.local PLIVO_AUTH_ID=""YOUR_ACTUAL_PLIVO_AUTH_ID"" PLIVO_AUTH_TOKEN=""YOUR_ACTUAL_PLIVO_AUTH_TOKEN"" PLIVO_SENDER_NUMBER=""+12025551234"" # Your purchased Plivo number # ... other variables -
Add Test Numbers: During the free trial, Plivo requires you to verify destination numbers. Go to
Phone Numbers->Sandbox Numbersand add the phone number(s) you intend to send test messages to.
4.2 Vercel Postgres Setup
- Create Database:
- Go to your Vercel Dashboard.
- Navigate to the
Storagetab. - Click
Create Databaseand selectPostgres. - Choose a region, give it a name (e.g.,
plivo-scheduler-db), and create it.
- Connect Project:
- Select the Vercel project you intend to deploy this application to (or create one by importing your Git repository).
- Click
Connect Project.
- Get Connection Strings:
- Once connected, go to the database settings (
.env.localtab). - Vercel provides several environment variables. Copy the values for
POSTGRES_PRISMA_URLandPOSTGRES_URL_NON_POOLING.
- Once connected, go to the database settings (
- Update Environment Variables: Paste these connection strings into your local
.env.localfile, replacing the placeholders.
4.3 Apply Database Schema
With the database connection string configured in .env.local, apply your Prisma schema to the Vercel Postgres database.
# Ensure your .env.local has the Vercel Postgres URLs
npx prisma db push- This command synchronizes your database schema with the definition in
prisma/schema.prisma. It's suitable for development and simple production setups. For complex changes requiring explicit migration steps later, consider usingprisma migrate dev(local) andprisma migrate deploy(CI/CD).
5. Implementing error handling, logging, and retries
Our current code has basic try...catch blocks and console.log statements. Let's refine this.
- Error Handling:
- The API route (
/api/schedule) uses Zod for specific validation errors and returns 400 status codes. - General server errors return a 500 status.
- The
sendPlivoSmsfunction returns a{ success: boolean, error?: string }object, allowing the caller (cron job) to handle Plivo-specific failures.
- The API route (
- Logging:
console.logandconsole.errorare used throughout. For production, consider integrating a dedicated logging service (like Logtail, Datadog, Axiom) by replacingconsolecalls or using a library likepino.- Vercel automatically captures
consoleoutput from Serverless Functions and Cron Jobs, viewable in the deployment logs.
- Retries:
- Plivo: Plivo handles some level of carrier-side retries internally.
- Cron Job: Vercel Cron Jobs have built-in basic retry mechanisms if the handler function itself fails (e.g., throws an unhandled error or returns a 5xx status).
- Application-Level Retries: The current cron job logic (Section 12.1 - Note: Section 12.1 was referenced but not provided in the original text) implements a simple retry strategy by design: if sending an SMS fails and the schedule remains
PENDING(or is markedFAILEDbut you add logic to retryFAILEDones), the next scheduled run of the cron job will attempt to process it again. This relies on subsequent job invocations, not an immediate retry within the same failed invocation. For more sophisticated retries (e.g., exponential backoff for specific errors), a dedicated job queue system (like BullMQ with Redis) would be needed.
Example: Testing Error Scenario
- Temporarily put an invalid
PLIVO_AUTH_IDin your.env.local. - Trigger the cron job manually (see Section 12.2 - Note: Section 12.2 was referenced but not provided in the original text) or wait for it to run.
- Check the Vercel logs for the
/api/cronfunction. You should see error messages from thesendPlivoSmsfunction indicating authentication failure. The schedule status in the database should ideally be updated toFAILED.
6. Database schema and data layer
We defined the schema (prisma/schema.prisma) and set up the Prisma client (lib/prisma.ts) in Section 2. The Schedule model and its indices are designed for this use case.
-
Entity Relationship Diagram (ERD): In this simple case, we only have one main entity:
Schedule.(Note: The following diagram uses Mermaid syntax. Ensure your publishing platform supports Mermaid rendering, or replace this with a static image alternative.)
mermaiderDiagram SCHEDULE { String id PK String phoneNumber String message DateTime sendAt String status ""PENDING, SENT, FAILED"" String plivoMessageIdnullable String errornullable DateTime createdAt DateTime updatedAt } -
Data Access: All database interactions happen via the
prismaclient instance, providing type safety and abstracting SQL. The primary query pattern is in the cron handler (Section 12.1 - Note: Section 12.1 was referenced but not provided in the original text) where we fetch pending schedules due to be sent. -
Migrations: We used
prisma db pushfor simplicity. For production evolution, useprisma migrate devlocally to generate SQL migration files andprisma migrate deployin your deployment pipeline (CI/CD) to apply them reliably. -
Sample Data: You can use Prisma Studio to manually add test data.
bashnpx prisma studioThis opens a web UI where you can view and manipulate your database data.
7. Adding security features
- Input Validation: Zod in
app/api/schedule/route.tsprovides strict validation of incoming data, preventing malformed requests and basic injection attempts. Sanitize or carefully handle message content if rendering it elsewhere later. - Environment Variables: Secrets (
PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN,POSTGRES_...,CRON_SECRET) are stored securely in environment variables and not committed to Git (.env.local). Use Vercel's environment variable management for deployment. - Cron Job Security: (Note: The original text ended here. Further details on Cron Job Security were likely intended but not provided.)
Frequently Asked Questions
How to schedule SMS reminders with Next.js?
Use Next.js API routes to handle scheduling requests, store them in a database (like Vercel Postgres), and trigger sending via a cron job. The frontend UI collects recipient details, message, and scheduled time, while the backend manages storage and interaction with the Plivo SMS API.
What is Plivo used for in this project?
Plivo is the cloud communications platform used to actually send the SMS messages. The Next.js app interacts with Plivo's API using the Plivo Node.js SDK, providing the recipient's phone number and message content.
Why use Vercel Cron Jobs for SMS scheduling?
Vercel Cron Jobs offer a serverless way to trigger the SMS sending process at specified intervals. The cron job calls a dedicated Next.js API route, which queries the database for pending messages and initiates sending via Plivo.
When should I use Prisma db push vs. Prisma Migrate?
Use `prisma db push` for initial setup and simple schema updates during development. For production environments or complex schema changes, use `prisma migrate dev` (locally) and `prisma migrate deploy` (in CI/CD) for safer, versioned migrations.
How to set up Vercel Postgres for the project?
Create a Postgres database from the Vercel Dashboard Storage tab. Connect this database to your Vercel project, and then copy the provided `POSTGRES_PRISMA_URL` and `POSTGRES_URL_NON_POOLING` connection strings into your project's `.env.local` file.
What is the role of Prisma in the SMS scheduler?
Prisma acts as the Object-Relational Mapper (ORM) simplifying database interactions. It enables type-safe data access, schema management, and handles the connection to the Vercel Postgres database.
How to send SMS messages with Plivo?
After setting up a Plivo account and purchasing a number, use the Plivo Node.js SDK within your Next.js API route. Provide your Plivo Auth ID, Auth Token, sender number, recipient number, and message body to the `client.messages.create` method.
What's the purpose of the status field in the Schedule model?
The `status` field in the `Schedule` model tracks the state of each scheduled message (PENDING, SENT, or FAILED). This allows the cron job to identify which messages need processing and provides a record of successful and failed attempts.
How does the cron job handle time zones?
The application stores all dates and times in UTC. The frontend handles time zone conversion, ensuring that the backend receives and stores times in UTC, preventing discrepancies and ensuring messages are sent at the correct time.
Can I test the Next.js API route locally?
Yes, run `npm run dev` to start the development server. Then use `curl`, Postman, or Insomnia to send test POST requests to the API endpoint (`/api/schedule`), providing the necessary data in JSON format.
How to implement error handling for Plivo API calls?
Wrap your Plivo API calls in a `try...catch` block. The `sendPlivoSms` helper function already provides error handling and returns a result object with a `success` flag and an optional `error` message to handle specific issues.
Why does the code use zod for schema validation?
Zod provides robust schema validation for incoming API requests, ensuring that data conforms to the expected format and preventing potential errors or security vulnerabilities from malformed data.
What is the recommended way to store sensitive information like Plivo credentials?
Store Plivo credentials (Auth ID, Auth Token), database URLs, and other secrets in environment variables (`.env.local`). This file is automatically excluded from Git, preventing accidental exposure.
How to view logs for the Vercel Cron Job?
Vercel automatically captures console output from Serverless Functions and Cron Jobs. You can view these logs in your Vercel project dashboard under the deployments section for the corresponding function.