code examples
code examples
How to Build SMS Appointment Reminders with Sinch and Next.js | Complete Tutorial
Learn how to build automated SMS appointment reminders using Sinch SMS API and Next.js. Complete guide with OAuth2 setup, send_at scheduling, Luxon time zones, and production deployment.
Learn how to build an automated SMS appointment reminder system using the Sinch SMS API and Next.js. This complete tutorial demonstrates scheduling SMS messages with the Sinch send_at parameter, implementing OAuth2 authentication, managing time zones with Luxon, and deploying a production-ready notification system. You'll create an application that schedules appointment reminders hours or days in advance using the Sinch Batches API with proper UTC time conversion.
This step-by-step guide covers everything from initial project setup through production deployment, showing you how to integrate the Sinch Node.js SDK with Next.js API routes for automated patient appointment reminders.
Important: Sinch SMS API Authentication Methods
Sinch SMS API supports two authentication methods (Source: @sinch/sdk-core npm documentation, verified January 2025):
- OAuth2 Authentication (used in this guide): Available for US (
us) and EU (eu) regions only. RequiresprojectId,keyId, andkeySecret. - API Token Authentication: Available for all regions including US, EU, Brazil (
br), Canada (ca), and Australia (au). RequiresservicePlanId,apiToken, andsmsRegion.
This tutorial uses OAuth2 authentication (Option 1). If you need other regions (BR, CA, AU), use API Token authentication instead. Find API Token credentials in the Sinch dashboard under "Service APIs."
What You'll Build: SMS Appointment Reminder System
You'll build a Next.js web application that allows administrative users to schedule SMS appointment reminders through a simple form interface. Users input patient appointment details (name, doctor, date/time, mobile number), and the application uses the Sinch SMS API to automatically send an SMS reminder two hours before each appointment.
Problem Solved: This automated appointment reminder system reduces patient no-shows and eliminates manual reminder calls, improving operational efficiency for medical practices, salons, and service businesses. Learn more about SMS best practices for appointment reminders and 10DLC registration requirements for high-volume messaging.
Technologies Used:
- Next.js: A React framework for building server-rendered or statically generated web applications. Provides file-based routing and API routes, simplifying development.
- Node.js: The JavaScript runtime environment used by Next.js.
- Sinch SMS API & Node SDK (
@sinch/sdk-core): Sends and schedules SMS messages programmatically. Chosen for its direct scheduling capability (send_atparameter). - Luxon: A powerful library for handling dates and times, crucial for scheduling and time zone management.
- React: For building the user interface components.
Architecture:
- Client (Browser): User interacts with a Next.js page (React component) containing the scheduling form.
- Next.js Frontend: The React component captures form input using state. On submission, it sends a POST request to a Next.js API route.
- Next.js API Route: A serverless function that receives the request, validates the data (especially the appointment time), initializes the Sinch SDK client, formats the phone number and message, calculates the scheduled send time (2 hours before appointment, converted to UTC), and calls the Sinch API's
sms.batches.sendmethod with thesend_atparameter. - Sinch API: Receives the request, validates credentials, schedules the SMS message, and sends it at the specified time.
Understanding the Sinch send_at Parameter for Scheduled SMS
The Sinch Batches API provides native SMS scheduling functionality, eliminating the need for external job schedulers or cron jobs. Key parameters (Source: Sinch SMS API Batches documentation, verified January 2025):
send_at: ISO-8601 formatted datetime string in UTC (format:YYYY-MM-DDThh:mm:ss.SSSZ). If set in the future, message delivery is delayed until this time. If set in the past, messages send immediately. Must be beforeexpire_at.expire_at: ISO-8601 formatted datetime string in UTC. The system stops attempting delivery at this point. Default: 3 days aftersend_at. Maximum: 3 days aftersend_at. Must be aftersend_at.to: Array of phone numbers in E.164 format. Can contain 1 to 1,000 recipients per batch.body: Message content. Maximum 2,000 characters. Messages exceeding standard SMS length (160 chars GSM-7, 70 chars Unicode) are automatically split into segments.
Outcome: A functional web application that accepts appointment details and schedules SMS reminders via Sinch.
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Sinch account with access to the SMS API.
- A provisioned phone number (virtual number) in your Sinch account to use as the
FROM_NUMBER. - Sinch API Credentials (Project ID, Key ID, Key Secret).
- Basic understanding of React and Next.js concepts.
- The patient's phone number must be verified in your Sinch account for testing purposes (this restriction is often lifted for production accounts).
Recommended Node.js Version: Node.js 20.x or 22.x (LTS versions for 2025). Node.js 18.x enters end-of-life in April 2025 and should not be used for new projects.
Step 1: Next.js Project Setup and Dependencies
Start by creating a new Next.js project and installing the Sinch SDK and time zone management libraries. For developers working with other SMS providers, you might also reference our guides on Twilio SMS integration and MessageBird implementation patterns.
-
Create Next.js App: Open your terminal and navigate to the directory where you want to create your project. Run:
bashnpx create-next-app@latest sinch-scheduler --typescript # or # yarn create next-app sinch-scheduler --typescriptFollow the prompts (you can accept the defaults). This guide uses TypeScript for better type safety.
-
Navigate to Project Directory:
bashcd sinch-scheduler -
Install Dependencies: Install the Sinch SDK and Luxon for date/time manipulation.
bashnpm install @sinch/sdk-core luxon npm install --save-dev @types/luxon # or # yarn add @sinch/sdk-core luxon # yarn add --dev @types/luxon@sinch/sdk-core: The official Sinch Node.js SDK.luxon: For robust date/time handling and time zone conversions.@types/luxon: TypeScript definitions for Luxon.
-
Environment Variables: Create a file named
.env.localin the root of your project. This file stores sensitive credentials and configuration securely. Never commit this file to Git.Obtaining Sinch Credentials:
- Log in to your Sinch Customer Dashboard.
- Navigate to the "Access Keys" section (usually under your user profile or settings). Dashboard layouts change over time. Look for sections related to "API Management," "API Keys," "Access Keys," or specific service credentials like "SMS API."
- Note your
Project ID. - If you don't have an Access Key pair, create one. Note the
Key IDandKey Secret. Store the Key Secret securely; you won't see it again. - Navigate to your SMS Service Plan details (often linked from the main dashboard or Numbers section). Find the virtual phone number assigned to your account – this is your
FROM_NUMBER. Ensure it's in E.164 format (e.g.,+12025550181). - Determine your Sinch SMS API region (
usoreu). This usually corresponds to where you set up your account.
Populate
.env.local:dotenv# .env.local # Sinch API Credentials SINCH_PROJECT_ID='YOUR_project_id' SINCH_KEY_ID='YOUR_key_id' SINCH_KEY_SECRET='YOUR_key_secret' # Sinch Configuration SINCH_FROM_NUMBER='YOUR_sinch_number' # e.g., +12025550181 SINCH_SMS_REGION='us' # or 'eu' # Optional: Default country code for formatting numbers if needed # Example for US: DEFAULT_COUNTRY_CODE='+1' # Example for UK: # DEFAULT_COUNTRY_CODE='+44'Security: Protect Your Credentials
Add
.env.localto your.gitignorefile immediately to prevent committing sensitive credentials:bash# Add to .gitignore .env.local .env*.localNext.js automatically excludes
.env.localfrom Git, but verify your.gitignoreincludes this pattern. Never commit API credentials to version control.Explanation:
SINCH_PROJECT_ID,SINCH_KEY_ID,SINCH_KEY_SECRET: Used to authenticate with the Sinch API via the SDK.SINCH_FROM_NUMBER: The phone number that appears as the sender of the SMS.SINCH_SMS_REGION: Specifies the Sinch regional endpoint (important for data residency and performance).DEFAULT_COUNTRY_CODE: A helper for potentially formatting recipient numbers if entered without a country code (implementation varies).
-
Project Structure (Simplified): Next.js provides a standard structure. You'll primarily work within:
pages/: Contains frontend pages and API routes.pages/index.tsx: Your main scheduling form page.pages/api/schedule.ts: Your API route to handle form submission and interact with Sinch.
public/: For static assets (images, CSS if not using modules/tailwind)..env.local: Environment variables (created above).styles/: For global styles or CSS modules.
Step 2: Creating the Appointment Scheduling Form
Create the user interface for scheduling appointments using basic React state management and standard HTML form elements.
-
Clear Default Content: Open
pages/index.tsxand replace its content with the following structure. -
Implement the Form:
typescript// pages/index.tsx import React, { useState, FormEvent } from 'react'; import Head from 'next/head'; import styles from '../styles/Home.module.css'; // Example using CSS Modules export default function Home() { const [patientName, setPatientName] = useState(''); const [doctorName, setDoctorName] = useState(''); const [appointmentDate, setAppointmentDate] = useState(''); const [appointmentTime, setAppointmentTime] = useState(''); const [patientPhone, setPatientPhone] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); setIsLoading(true); setStatusMessage(''); setIsError(false); try { const response = await fetch('/api/schedule', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ patient: patientName, doctor: doctorName, appointmentDate: appointmentDate, appointmentTime: appointmentTime, phone: patientPhone, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.message || 'Failed to schedule appointment.'); } setStatusMessage( `Reminder scheduled for ${patientName} at ${appointmentDate} ${appointmentTime}.` ); // Optionally clear the form setPatientName(''); setDoctorName(''); setAppointmentDate(''); setAppointmentTime(''); setPatientPhone(''); } catch (error: any) { console.error('Scheduling Error:', error); setStatusMessage(`Error: ${error.message}`); setIsError(true); } finally { setIsLoading(false); } }; return ( <div className={styles.container}> <Head> <title>Sinch Appointment Scheduler</title> <meta name="description" content="Schedule SMS reminders via Sinch" /> <link rel="icon" href="/favicon.ico" /> </Head> <main className={styles.main}> <h1 className={styles.title}>Sinch Appointment Scheduler</h1> {statusMessage && ( <div className={isError ? styles.errorBox : styles.successBox}> {statusMessage} </div> )} <form onSubmit={handleSubmit} className={styles.form}> <div className={styles.formRow}> <label htmlFor="patientName">Patient Name:</label> <input type="text" id="patientName" value={patientName} onChange={(e) => setPatientName(e.target.value)} required placeholder="Patient name" /> </div> <div className={styles.formRow}> <label htmlFor="doctorName">Doctor Name:</label> <input type="text" id="doctorName" value={doctorName} onChange={(e) => setDoctorName(e.target.value)} required placeholder="Name of doctor" /> </div> <div className={styles.formRow}> <label htmlFor="appointmentDate">Appointment Date:</label> <input type="date" id="appointmentDate" value={appointmentDate} onChange={(e) => setAppointmentDate(e.target.value)} required /> </div> <div className={styles.formRow}> <label htmlFor="appointmentTime">Appointment Time:</label> <input type="time" id="appointmentTime" value={appointmentTime} onChange={(e) => setAppointmentTime(e.target.value)} required /> </div> <div className={styles.formRow}> <label htmlFor="patientPhone">Patient Mobile Number:</label> <input type="tel" id="patientPhone" value={patientPhone} onChange={(e) => setPatientPhone(e.target.value)} required placeholder="+1234567890" /> </div> <button type="submit" disabled={isLoading} className={styles.submitButton}> {isLoading ? 'Scheduling…' : 'Schedule Reminder'} </button> </form> </main> </div> ); } -
Add Basic Styling (
styles/Home.module.css): Create or modifystyles/Home.module.csswith some basic styles. This mirrors the intent of the original tutorial's CSS but uses CSS Modules.css/* styles/Home.module.css */ .container { padding: 0 2rem; background: #f0f2f5; min-height: 100vh; display: flex; flex-direction: column; align-items: center; } .main { padding: 4rem 0; flex: 1; display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 600px; } .title { margin: 0; line-height: 1.15; font-size: 3rem; text-align: center; margin-bottom: 2rem; color: #090931; } .form { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); width: 100%; } .formRow { margin-bottom: 1rem; display: flex; flex-direction: column; } .formRow label { margin-bottom: 0.5rem; font-weight: bold; color: #333; } .formRow input[type='text'], .formRow input[type='date'], .formRow input[type='time'], .formRow input[type='tel'] { padding: 0.75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; width: 100%; box-sizing: border-box; } .submitButton { background-color: #0070f3; color: white; padding: 0.75rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; font-weight: bold; transition: background-color 0.2s ease; width: 100%; margin-top: 1rem; } .submitButton:hover { background-color: #005bb5; } .submitButton:disabled { background-color: #ccc; cursor: not-allowed; } .statusBox { padding: 1rem; margin-bottom: 1.5rem; border-radius: 4px; text-align: center; width: 100%; box-sizing: border-box; } .successBox { composes: statusBox; background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .errorBox { composes: statusBox; background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
Explanation:
- Use
useStatehooks to manage input values for each form field and track loading/status messages. handleSubmittriggers on form submission. It prevents the default form action, sets loading state, and makes afetchrequest to the backend API route (/api/schedule).- The request body sends form data as JSON.
- The code handles the response, displaying success or error messages returned from the API.
- Basic CSS Modules provide scoped CSS.
Step 3: Building the SMS Scheduling API Route
This is the core server-side logic where you interact with the Sinch SDK.
-
Create API Route File: Create the file
pages/api/schedule.ts. -
Implement the Handler:
typescript// pages/api/schedule.ts import type { NextApiRequest, NextApiResponse } from 'next'; import { SinchClient } from '@sinch/sdk-core'; import { DateTime } from 'luxon'; type ApiResponse = { message: string; batchId?: string; }; // Initialize Sinch Client outside handler for reuse // Note: Using non-null assertion (!) assumes env variables are set. // Consider adding runtime checks or using a config validation library (e.g., Zod) // in production to prevent crashes if missing. const sinchClient = new SinchClient({ projectId: process.env.SINCH_PROJECT_ID!, keyId: process.env.SINCH_KEY_ID!, keySecret: process.env.SINCH_KEY_SECRET!, }); const FROM_NUMBER = process.env.SINCH_FROM_NUMBER!; const DEFAULT_COUNTRY_CODE = process.env.DEFAULT_COUNTRY_CODE || ''; export default async function handler( req: NextApiRequest, res: NextApiResponse<ApiResponse> ) { if (req.method !== 'POST') { res.setHeader('Allow', ['POST']); return res.status(405).json({ message: `Method ${req.method} not allowed` }); } // Runtime check for essential environment variables if (!process.env.SINCH_PROJECT_ID || !process.env.SINCH_KEY_ID || !process.env.SINCH_KEY_SECRET || !FROM_NUMBER) { console.error('Missing required Sinch environment variables.'); return res.status(500).json({ message: 'Server configuration error.' }); } const { patient, doctor, appointmentDate, appointmentTime, phone } = req.body; // Basic Input Validation if (!patient || !doctor || !appointmentDate || !appointmentTime || !phone) { return res.status(400).json({ message: 'Missing required fields.' }); } // Date/Time Processing and Validation try { // Combine date and time, assuming local time zone of the server/user input // IMPORTANT: For production, explicitly handle the user's time zone. const localAppointmentDateTime = DateTime.fromISO( `${appointmentDate}T${appointmentTime}` ); if (!localAppointmentDateTime.isValid) { throw new Error(`Invalid date or time format: ${localAppointmentDateTime.invalidReason}`); } // Calculate reminder time (2 hours before appointment) const localReminderDateTime = localAppointmentDateTime.minus({ hours: 2 }); // Validate reminder time is in the future (at least 5 mins from now) const now = DateTime.now(); const minReminderTime = now.plus({ minutes: 5 }); if (localReminderDateTime < minReminderTime) { return res.status(400).json({ message: `Schedule appointments at least 2 hours and 5 minutes in advance. Cannot schedule reminder for ${localReminderDateTime.toLocaleString(DateTime.DATETIME_MED)}.`, }); } // Format Phone Number (Basic Example) // Ensure E.164 format (e.g., +1xxxxxxxxxx) let formattedToNumber = phone.trim(); if (!formattedToNumber.startsWith('+') && DEFAULT_COUNTRY_CODE) { // Basic attempt to add default country code if missing '+' // WARNING: Not foolproof for production formattedToNumber = DEFAULT_COUNTRY_CODE + formattedToNumber.replace(/[^0-9]/g, ''); } else if (!formattedToNumber.startsWith('+')) { return res.status(400).json({ message: 'Invalid phone number format. Include country code starting with +.' }); } // For production, implement robust E.164 validation using a library // like `google-libphonenumber` or a stricter regular expression. // Construct SMS Message const messageBody = `Reminder: Hi ${patient}, you have an appointment with Dr. ${doctor} on ${localAppointmentDateTime.toLocaleString(DateTime.DATE_MED)} at ${localAppointmentDateTime.toLocaleString(DateTime.TIME_SIMPLE)}.`; // Schedule SMS via Sinch // Convert reminder time to UTC ISO string for Sinch API const sendAtUTC = localReminderDateTime.toUTC().toISO(); console.log(`Scheduling SMS to ${formattedToNumber} from ${FROM_NUMBER} at UTC: ${sendAtUTC}`); const response = await sinchClient.sms.batches.send({ sendSMSRequestBody: { to: [formattedToNumber], from: FROM_NUMBER, body: messageBody, send_at: sendAtUTC, }, }); console.log('Sinch API Response:', response); return res.status(200).json({ message: 'Appointment reminder scheduled successfully.', batchId: response.id, }); } catch (error: any) { console.error('API Error:', error); // Handle potential Sinch SDK errors or other exceptions let errorMessage = 'Failed to schedule reminder.'; if (error.response?.data) { errorMessage += ` Sinch Error: ${JSON.stringify(error.response.data)}`; } else if (error instanceof Error) { errorMessage = error.message; } return res.status(500).json({ message: errorMessage }); } }
Explanation:
- The handler checks for the POST method first.
- It extracts appointment details from
req.body. - Date/Time Handling (Luxon): Parses date and time strings into a Luxon
DateTimeobject. Calculates the reminder time (2 hours prior) and validates that this reminder time is still in the future (at least 5 minutes from now). - Phone Number Formatting: Includes a basic check for the '+' prefix and attempts to add a default country code if missing. Implement robust E.164 validation using libraries or stricter regex for production.
- Sinch Client: Initializes the
SinchClientusing environment variables. A runtime check is included for critical variables. - Scheduling: Constructs the SMS message body. Crucially, converts the calculated
localReminderDateTimeto UTC (.toUTC().toISO()) before passing it to thesend_atparameter in thesinchClient.sms.batches.sendcall. Sinch expectssend_atin UTC. - Error Handling: Uses a
try...catchblock to handle errors during validation, date processing, or the Sinch API call. It logs the error and returns a 500 status with an informative message. It attempts to extract specific error details if available from the Sinch SDK error structure. - Response: Returns a 200 status with a success message and the Sinch batch ID on success, or appropriate error codes (400 for bad input, 405 for wrong method, 500 for server errors).
Step 4: Integrating Sinch SMS API with send_at Parameter
The primary integration happens within the API route (pages/api/schedule.ts):
- Initialization: The
SinchClientis initialized using yourPROJECT_ID,KEY_ID, andKEY_SECRETfrom.env.local. - Scheduling Call: The
sinchClient.sms.batches.send()method is the key function call.to: An array containing the recipient phone number(s) in E.164 format.from: Your provisioned Sinch number from.env.local.body: The text message content.send_at: The critical parameter for scheduling. Must be an ISO 8601 formatted string in UTC. Luxon's.toUTC().toISO()provides this correctly.
Security of Credentials: By storing keys in .env.local and accessing them via process.env, Next.js ensures they're only available server-side (in the API route) and not exposed to the client browser. Ensure .env.local is in your .gitignore file.
Step 5: Implementing Error Handling and Logging
- API Route: The
try...catchblock inpages/api/schedule.tsis the primary error handler.- It catches validation errors (missing fields, invalid dates/times).
- It catches errors thrown by the
SinchClientduring the API call (e.g., invalid credentials, malformed request, insufficient funds, invalid 'to' number). console.errorlogs on the server side (visible in your terminal or Vercel logs).
- Frontend: The
handleSubmitfunction inpages/index.tsxcatches errors during thefetchcall (network issues) or uses the response status (!response.ok) to identify API-level errors returned from the backend. It displays user-friendly messages. - Retry Mechanisms: This basic example doesn't include automatic retries. For production, implement retries with exponential backoff (using libraries like
async-retry) within the API route for transient network errors or specific Sinch error codes that indicate temporary issues. Be cautious not to retry errors related to invalid input or permanently failed states. - Logging Levels: For more advanced logging, consider libraries like
pinoorwinstonto structure logs, set different levels (debug, info, warn, error), and potentially send logs to external services (e.g., Datadog, Logtail).
6. Database Schema and Data Layer (Enhancement)
The current implementation doesn't persist appointments. Reminders are scheduled directly with Sinch based on transient form input.
To add persistence (Recommended for Production):
-
Choose a Database: PostgreSQL, MySQL, MongoDB, etc. Serverless options like Vercel Postgres or Neon are good fits for Next.js.
-
Install ORM/Client: Prisma (recommended for type safety), Drizzle ORM,
node-postgres,mysql2, etc. -
Define Schema: Create tables/collections (e.g.,
Appointments).sql-- Example PostgreSQL Schema (using Prisma syntax for illustration) model Appointment { id String @id @default(cuid()) patientName String doctorName String appointmentTime DateTime // Store in UTC patientPhone String // Store in E.164 reminderSentAt DateTime? // Timestamp when reminder was successfully scheduled/sent sinchBatchId String? // Store Sinch ID for tracking createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -
Modify API Route:
- Before calling Sinch, save the appointment details to the database.
- After a successful
sinchClient.sms.batches.sendcall, update the appointment record with thesinchBatchIdand perhaps a status indicating the reminder is scheduled.
-
Data Access Layer: Use your chosen ORM/client within the API route to interact with the database (create, read, update).
-
Migrations: Use tools like
prisma migrate devto manage schema changes.
7. Security Features
- Input Validation:
- Server-side (API Route): Crucial. Check for missing fields and valid date/time logic. Add more robust validation:
- Phone Number: Use a library like
google-libphonenumberor a strict regex to validate E.164 format (as recommended in Section 3). - String Lengths: Limit lengths for names, etc.
- Sanitization: Although less critical for these specific fields, libraries like
DOMPurify(if rendering user input) or escaping output are generally good practices. For data used in API calls (like phone numbers), ensure the format is strictly controlled.
- Phone Number: Use a library like
- Client-side (Frontend): HTML5
requiredattributes andtypeattributes provide basic validation but should not be relied upon for security.
- Server-side (API Route): Crucial. Check for missing fields and valid date/time logic. Add more robust validation:
- Authentication/Authorization: The current app is open. For real use, protect the API route:
- Implement user authentication (e.g., NextAuth.js, Clerk, Supabase Auth).
- Ensure only authorized users (e.g., admins) can call the
/api/scheduleendpoint.
- Rate Limiting: Protect the API route from abuse. Use libraries like
rate-limiter-flexibleor Vercel's built-in IP rate limiting. - Environment Variables: Keep secrets (
SINCH_KEY_SECRET) out of the codebase and client-side bundles using.env.local. Perform runtime checks for critical variables in the API route. - HTTPS: Ensure deployment uses HTTPS (Vercel handles this automatically).
- Dependency Updates: Regularly update dependencies (
npm updateoryarn upgrade) to patch known vulnerabilities.
Managing Time Zones for SMS Scheduling
Time zones are critical for scheduling.
Problem: The user enters a date and time in their local time zone. The Sinch API's send_at parameter requires UTC. The server running the API route might be in a different time zone altogether.
Solution (using Luxon in API Route):
- Capture Input: The HTML
<input type="date">and<input type="time">capture local date/time strings based on the user's browser/OS settings. - Parse with Awareness (Robust Approach): The most robust approach, especially for applications serving users across multiple time zones, is to capture the user's IANA time zone (e.g.,
America/New_York) on the frontend (usingIntl.DateTimeFormat().resolvedOptions().timeZone) and send it to the backend. Then parse using that zone:DateTime.fromISO(isoString, { zone: userTimeZone }). - Parse as Server's Local (Simpler but Less Accurate – Used Here): The current code
DateTime.fromISO(${appointmentDate}T${appointmentTime})implicitly uses the server's time zone if no zone info is in the string. This works if users are in the same timezone as the server, but is brittle for wider audiences. - Calculate Reminder Time: Perform subtractions (
minus({ hours: 2 })) on the Luxon object. - Convert to UTC for Sinch: Always use
.toUTC().toISO()before sending thesend_atvalue to Sinch.
Recommendation: For robust applications, capture and use the user's specific time zone during parsing. While the simplified approach is used here for demonstration, capturing the user's timezone explicitly on the frontend and sending it to the backend is the best practice for production applications. Clearly document the chosen assumption (server's local time for parsing input).
9. Performance Optimizations (Less Critical Here)
For this simple application, performance bottlenecks are unlikely. However, for scaling:
- API Route Cold Starts: Serverless functions have cold starts. Keep the API route lean. Initialize clients like
SinchClientoutside the handler function scope (as done in the example) so they persist between invocations (within limits). - Database Queries (if added): Index database columns used in queries (e.g.,
appointmentTime). - Bundle Size: Keep the frontend JavaScript bundle size reasonable. Next.js handles code splitting automatically.
- Caching: Not directly applicable for the scheduling action itself, but could be used for fetching static data if the app grew.
10. Monitoring, Observability, and Analytics
- Vercel Analytics: If deploying on Vercel, enable its built-in analytics for traffic insights.
- Sinch Dashboard: Monitor SMS delivery status, usage, and potential errors in the Sinch Customer Dashboard. Check delivery reports (DLRs) if configured.
- Logging: Implement structured logging (as mentioned in Error Handling) and potentially send logs to a centralized service (Datadog, Logtail, Sentry) for easier searching and alerting.
- Error Tracking: Use services like Sentry to capture and aggregate frontend and backend errors automatically.
- Uptime Monitoring: Use services like UptimeRobot or Checkly to monitor the availability of your application endpoint.
Testing Your SMS Appointment Reminder System
Test your implementation thoroughly before production deployment.
Unit Tests:
- Test date/time calculation logic independently.
- Test phone number formatting functions.
- Test input validation logic.
Integration Tests:
- Test the complete flow from form submission to Sinch API call.
- Use Sinch test credentials or a sandbox environment.
- Verify scheduled messages appear in Sinch dashboard.
Manual Testing:
- Schedule an appointment 3+ hours in the future.
- Verify the SMS arrives 2 hours before the appointment time.
- Test edge cases: appointments in the past, invalid phone numbers, missing fields.
- Test with different time zones if your application serves multiple regions.
Deploying to Production
Deploy your Next.js application to production.
Vercel (Recommended):
- Push your code to GitHub, GitLab, or Bitbucket.
- Import your repository in Vercel.
- Add environment variables in Vercel project settings (same as
.env.local). - Deploy.
Other Platforms:
- AWS Amplify
- Netlify
- Railway
- Self-hosted with Node.js
Post-Deployment Checklist:
- Verify environment variables are set correctly.
- Test the scheduling flow in production.
- Monitor logs for errors.
- Set up alerts for failed SMS deliveries.
- Configure rate limiting.
- Implement authentication if handling sensitive data.
Conclusion
You've built a complete SMS appointment reminder system using Sinch and Next.js. The application schedules SMS messages using the send_at parameter, handles time zone conversions with Luxon, and provides a clean user interface for appointment management.
Next Steps:
- Add database persistence for appointment records.
- Implement user authentication and authorization.
- Add webhook handlers for delivery receipt notifications.
- Expand to support multiple reminder types (email, push notifications).
- Implement more robust phone number validation.
- Add time zone selection for users.
- Set up comprehensive monitoring and alerting.
Frequently Asked Questions
How to schedule SMS reminders with Sinch?
Use the Sinch SMS API's `sms.batches.send` method with the `send_at` parameter. This parameter allows you to specify the exact time the SMS should be sent, enabling scheduled reminders. The time must be provided in UTC using ISO 8601 format as a string in the API request body. It's crucial to convert times to UTC before scheduling reminders with the Sinch API.
What is Sinch used for in appointment reminders?
Sinch provides the SMS API and Node.js SDK that enables sending and scheduling SMS messages programmatically. The Sinch API handles the actual sending of the SMS messages at the specified time, leveraging its robust infrastructure for reliable message delivery. It also offers number provisioning and verification services.
Why use Next.js for SMS scheduling?
Next.js simplifies building the user interface and API routes needed for this application, providing file-based routing and serverless functions for cleaner code. Using a framework like Next.js provides structure and simplifies routing between pages. This makes it easy to manage your application’s frontend and backend within a single project.
When should I convert time to UTC for Sinch?
Always convert the scheduled time to UTC *before* passing it to the Sinch API's `send_at` parameter. The Sinch API expects UTC, and failing to convert can lead to reminders being sent at the wrong time. Luxon's `.toUTC().toISO()` helps with this conversion.
How to set up a Next.js project with Sinch?
Use `npx create-next-app@latest sinch-scheduler --typescript` to initialize the project. Then install the Sinch SDK (`@sinch/sdk-core`) and Luxon (`luxon`) libraries using npm or yarn. Configure the Sinch credentials (Project ID, Key ID, Key Secret, FROM_NUMBER, SMS Region) as environment variables in a `.env.local` file.
What is the role of Luxon in Sinch integration?
Luxon is essential for handling dates and times, particularly converting between time zones and calculating the correct UTC time for the Sinch API. Luxon provides robust methods for parsing, formatting, and manipulating dates and times, which is essential for accurate scheduling. It simplifies time zone conversions, a crucial aspect when working with UTC for the Sinch API.
How to handle time zones with Sinch SMS API?
Capture the user's time zone on the frontend (using `Intl.DateTimeFormat().resolvedOptions().timeZone`) and send this to the Next.js backend. Then use Luxon to parse the date/time string with the user's time zone. For the simplified approach used in this tutorial, the server's time zone is implicitly used for parsing, but this is not suitable if users are across multiple time zones. However, always convert to UTC using `.toUTC().toISO()` before sending to Sinch.
What are the Sinch API credentials needed?
You need your Sinch Project ID, Key ID, and Key Secret from the Sinch Customer Dashboard to initialize the Sinch SDK. You also need a 'FROM_NUMBER', which is your provisioned Sinch virtual number and the Sinch SMS API region (`us` or `eu`), usually corresponding to your account location.
How to format phone numbers for Sinch?
Phone numbers sent to the Sinch API must be in E.164 format (e.g., +1234567890). The tutorial provides a basic prefix check but recommends using the `google-libphonenumber` library (or similar) in production for robust validation.
What is the purpose of the FROM_NUMBER in Sinch?
The FROM_NUMBER is your provisioned Sinch virtual number and is the number that will appear as the sender of the SMS messages. This number must be configured in your Sinch account. Ensure it's in E.164 format and set it in your `.env.local` file.
What database schema is recommended for storing appointments?
A suggested database schema includes fields for `patientName`, `doctorName`, `appointmentTime` (stored in UTC), `patientPhone` (E.164 format), `reminderSentAt` (timestamp), `sinchBatchId` (for tracking), and standard `createdAt`/`updatedAt` timestamps.
Can I add database persistence to the scheduler?
Yes, database persistence is recommended for production applications. Choose a database (e.g., PostgreSQL, MySQL), install an ORM/client (e.g., Prisma), define a schema, modify the API route to save/update appointments, and handle data access. This will ensure the SMS will still be sent if the web app is down during the scheduled time.
How can I improve the security of the SMS scheduler?
Implement robust input validation (especially for phone numbers), add user authentication/authorization to protect the API route, use rate limiting, secure environment variables, and keep dependencies up-to-date.
What are some performance considerations for Sinch scheduling?
Minimize API route cold starts by initializing clients outside the handler. Optimize database queries if you're using a database for persistence. Keep frontend bundle sizes reasonable and consider caching where appropriate. If possible, use a dedicated server rather than Serverless functions to avoid cold starts.
How to monitor the SMS reminder application?
Use Vercel Analytics (if deploying on Vercel), monitor SMS delivery status in the Sinch Dashboard, implement structured logging, integrate error tracking services like Sentry, and set up uptime monitoring.