code examples

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

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.)

mermaid
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.

  1. Create Next.js App: Open your terminal and run the following command, choosing options like TypeScript, Tailwind CSS, App Router, etc., when prompted.

    bash
    npx create-next-app@latest plivo-scheduler-app
    cd plivo-scheduler-app
  2. Install Dependencies: Add Plivo, Prisma, and date-fns for date manipulation.

    bash
    npm install plivo @prisma/client date-fns date-fns-tz zod
    npm install --save-dev prisma
    • plivo: 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.
  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: Open the .env file (renamed automatically from prisma/.env to the project root by create-next-app, or create .env.local if 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.local is ignored by Git by default, keeping your secrets safe. Never commit files containing sensitive credentials.
    • POSTGRES_PRISMA_URL vs POSTGRES_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.
  5. Project Structure: Your basic structure within the app directory 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
// 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 for PENDING schedules around the current time.

2.2 Prisma Client Setup (lib/prisma.ts)

Create a reusable Prisma client instance.

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

typescript
// 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 messageUuid or 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.

typescript
// 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 useState for 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's zonedTimeToUtc to convert the user's input date/time (parsed in their selected time zone) into a UTC Date object before sending it to the backend API.
  • API Call: Uses fetch to 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-black to 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.

typescript
// 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.ts pattern.
  • Input Validation: Leverages zod to define a schema (scheduleSchema) and validate the request body. This ensures the phone number format is basic E.164, the message isn't empty, and scheduledAt is 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 suggests libphonenumber-js for better validation.
  • Database Interaction: Uses the imported prisma client to create a new schedule record.
  • UTC Handling: Assumes the scheduledAt string received from the client is already in UTC (ISO 8601 format), as prepared by the frontend. It parses this string into a Date object, which Prisma handles correctly as a UTC timestamp in the database.
  • Error Handling: Includes a try...catch block to handle potential errors during JSON parsing, validation, or database operations, returning appropriate HTTP status codes.
  • Response: Returns a 201 Created status 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:

bash
# !! 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 curl command.

4. Integrating with third-party services

4.1 Plivo Setup

  1. Sign Up: Create an account at Plivo.com.

  2. Get Credentials:

    • Navigate to your Plivo Console dashboard.
    • On the overview page, you'll find your Auth ID and Auth Token.
    • Copy these values.
  3. Buy a Phone Number:

    • Go to Phone Numbers -> Buy Numbers in 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).
  4. Update Environment Variables: Paste your Auth ID, Auth Token, and the purchased phone number into your .env.local file.

    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
  5. Add Test Numbers: During the free trial, Plivo requires you to verify destination numbers. Go to Phone Numbers -> Sandbox Numbers and add the phone number(s) you intend to send test messages to.

4.2 Vercel Postgres Setup

  1. Create Database:
    • Go to your Vercel Dashboard.
    • Navigate to the Storage tab.
    • Click Create Database and select Postgres.
    • Choose a region, give it a name (e.g., plivo-scheduler-db), and create it.
  2. Connect Project:
    • Select the Vercel project you intend to deploy this application to (or create one by importing your Git repository).
    • Click Connect Project.
  3. Get Connection Strings:
    • Once connected, go to the database settings (.env.local tab).
    • Vercel provides several environment variables. Copy the values for POSTGRES_PRISMA_URL and POSTGRES_URL_NON_POOLING.
  4. Update Environment Variables: Paste these connection strings into your local .env.local file, 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.

bash
# 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 using prisma migrate dev (local) and prisma 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 sendPlivoSms function returns a { success: boolean, error?: string } object, allowing the caller (cron job) to handle Plivo-specific failures.
  • Logging:
    • console.log and console.error are used throughout. For production, consider integrating a dedicated logging service (like Logtail, Datadog, Axiom) by replacing console calls or using a library like pino.
    • Vercel automatically captures console output 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 marked FAILED but you add logic to retry FAILED ones), 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

  1. Temporarily put an invalid PLIVO_AUTH_ID in your .env.local.
  2. 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.
  3. Check the Vercel logs for the /api/cron function. You should see error messages from the sendPlivoSms function indicating authentication failure. The schedule status in the database should ideally be updated to FAILED.

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.)

    mermaid
    erDiagram
        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 prisma client 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 push for simplicity. For production evolution, use prisma migrate dev locally to generate SQL migration files and prisma migrate deploy in your deployment pipeline (CI/CD) to apply them reliably.

  • Sample Data: You can use Prisma Studio to manually add test data.

    bash
    npx prisma studio

    This 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.ts provides 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.