code examples
code examples
Build an Appointment Scheduler with SMS Reminders Using RedwoodJS and Vonage
Learn how to build a full-stack appointment scheduling application with automated SMS reminders using RedwoodJS v8.x and Vonage SMS API v3.24.1. Complete tutorial with code examples.
Build an Appointment Scheduler with SMS Reminders Using RedwoodJS and Vonage
Learn how to build a full-stack appointment scheduling application with automated SMS reminders using RedwoodJS v8.x and the Vonage SMS API v3.24.1.[1][4] RedwoodJS – an opinionated, full-stack framework built on React, GraphQL, and Prisma – lets you create modern web applications with minimal configuration.[1] Combine it with Vonage's reliable SMS API, and you can send appointment confirmations and reminders to your users automatically.[4]
Real-world applications: This pattern is essential for medical practices (reducing no-show rates by 20-30%), service businesses (HVAC, automotive repair), consulting firms, salons, and educational institutions. Automated SMS reminders improve customer satisfaction and operational efficiency by ensuring clients remember their commitments without manual intervention.[5]
In this tutorial, you'll build a complete appointment scheduling system. Users can book appointments through a web interface, and your application will send SMS confirmations immediately and reminders 24 hours before each appointment. You'll work with RedwoodJS v8.x (2025), Node.js v22 LTS (active until October 2025, maintained until April 2027), Prisma ORM v6.16.0, and the Vonage SDK v3.24.1.[1][2][3][4]
Estimated completion time: 2-3 hours for basic implementation plus 1-2 hours for production deployment and testing. By the end, you'll have a production-ready appointment scheduler with automated SMS notifications.
This guide provides a complete walkthrough for building a web application using the RedwoodJS framework that enables users to book appointments and receive SMS reminders via the Vonage Messages API. We'll cover everything from project setup and core feature implementation to deployment and troubleshooting.
By the end of this tutorial, you'll have a functional RedwoodJS application featuring:
- A user interface for selecting and booking appointment slots.
- Backend logic to manage appointment availability.
- Integration with the Vonage Messages API to send SMS confirmations upon booking.
- A scheduled mechanism to automatically send SMS reminders before appointments.
- Secure handling of API keys and user data.
- A robust structure ready for further development and deployment.
Target Audience: Developers familiar with JavaScript and Node.js, with some exposure to React and database concepts. Prior RedwoodJS experience is helpful but not strictly required.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework built on React, GraphQL, and Prisma. Current version: v8.x (2025). It provides structure, conventions, and tooling for rapid development.[1]
- Node.js: The underlying runtime environment for the RedwoodJS API side. Node.js v22 LTS is recommended (active LTS until October 2025, maintained until April 2027).[2]
- React: Used for building the user interface on the RedwoodJS web side.
- GraphQL: The communication layer between the RedwoodJS web and API sides.
- Prisma: A next-generation ORM for database access and migrations. Current version: v6.16.0 (2025), featuring the Rust-free architecture for improved performance and smaller bundle sizes.[3]
- Vonage Messages API: Used to send SMS confirmations and reminders (using
@vonage/server-sdkv3.24.1).[4] - PostgreSQL (or SQLite/MySQL): The database for storing appointment data. (This guide uses PostgreSQL syntax where applicable, but Prisma makes switching easy).
- External Scheduler (e.g., Vercel Cron Jobs, Netlify Scheduled Functions, OS cron): To trigger reminder checks via a Redwood Function.[6][7]
Project Overview and Goals
Business Context: No-shows cost service businesses an estimated $150 billion annually in the US alone. Automated appointment reminders can reduce no-shows by 20-30%, directly impacting revenue and operational efficiency. This system addresses the core problem: ensuring customers remember their commitments while minimizing manual administrative overhead.
We aim to build an application that solves the common problem of scheduling appointments and reducing no-shows through automated reminders with minimal human intervention.
Core Features:
- Appointment Booking: Users can view available time slots and book an appointment by providing their phone number.
- Instant Confirmation: Upon successful booking, the user receives an immediate SMS confirmation containing the appointment details and a unique cancellation code.
- Automated Reminders: The system automatically sends an SMS reminder to the user a configurable amount of time (e.g., 1 hour) before their scheduled appointment via a scheduled task.
- Appointment Cancellation: Users can cancel their appointment using the unique code provided in the confirmation SMS (Implementation of cancellation UI/API is outlined but left as an extension).
System Architecture:
+-------------------+ +-----------------------+ +---------------------+ +--------------------+
| User Browser | <--> | RedwoodJS Web Side | <--> | RedwoodJS API Side | <--> | PostgreSQL Database|
| (React UI) | | (React Components, | | (GraphQL, Services, | | (Prisma Client) |
+-------------------+ | Apollo Client) | | Functions) | +---------^----------+
+-----------------------+ +---------+-----------+
|
| (Vonage SDK v3.24.1)
v
+--------------------+
| Vonage Messages API|
+--------------------+
^
| (API Call via Function)
+---------+-----------+
| External Scheduler |
| (e.g., Vercel Cron, |
| Netlify Scheduled) |
+---------------------+
- Web Side: Handles user interaction (displaying the form, submitting data). Communicates with the API side via GraphQL over HTTPS.
- API Side: Contains business logic (checking availability, saving appointments, interacting with Vonage). Exposes a GraphQL API and serverless functions.
- Database: Stores appointment information (time, user phone number, reminder status) via Prisma ORM with connection pooling.
- Vonage API: External service used by the API side to send SMS messages following E.164 phone number standards.[8]
- External Scheduler: A process (like Vercel Cron Jobs or system
cron) runs periodically to trigger a Redwood Function (/api/sendReminders) which checks for upcoming appointments and triggers reminder SMS via the Vonage API.
Prerequisites:
- Node.js (v22 LTS recommended, minimum v18+) and npm or Yarn (v1 or later recommended)[2]
- Yarn (v1 or later) - This guide uses
yarncommands, butnpmequivalents generally work. - A Vonage API account (Sign up for free credit at https://developer.vonage.com/)
- Vonage Virtual Number: Purchase or rent a virtual phone number with SMS capability from the Vonage Dashboard. This number must be in E.164 format (e.g., +14155550100) and will be used as the sender for all SMS messages.[8]
- API Credentials: Obtain your API Key and API Secret from the Vonage Dashboard under "API settings".[8]
- Access to a PostgreSQL database (or choose SQLite/MySQL during Prisma setup)
- Basic command-line/terminal familiarity
- Tailwind CSS: The frontend examples assume Tailwind CSS is installed and configured in the RedwoodJS project (which is the default for new Redwood projects). Basic inline styles could be substituted if not using Tailwind.
1. Setting Up the RedwoodJS Project
Let's initialize our RedwoodJS application and configure the basic structure.
-
Create RedwoodJS App: Open your terminal and run:
bashyarn create redwood-app ./vonage-scheduler cd vonage-schedulerFollow the prompts. Choose TypeScript if you prefer, though this guide uses JavaScript.
Common installation issues:
- Port conflicts: If port 8910 is in use, RedwoodJS will prompt for an alternative or set
PORT=3000in.env - Yarn version issues: Ensure Yarn v1.22.0+ is installed (
yarn --version) - Node version: Verify Node.js v18+ with
node --version
- Port conflicts: If port 8910 is in use, RedwoodJS will prompt for an alternative or set
-
Install Dependencies: We need the Vonage Server SDK v3.24.1,
uuid, anddate-fns.[4]bashyarn workspace api add @vonage/server-sdk uuid date-fnsNote:
node-cronis removed as the scheduling logic relies on an external trigger for the function, not an in-process cron scheduler. -
Database Setup (Prisma): RedwoodJS uses Prisma v6.16.0 for database interaction.[3]
- Open
api/db/schema.prisma. - Configure the
datasource dbblock for your chosen database. For PostgreSQL with timezone support:prisma// api/db/schema.prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" binaryTargets = ["native"] } - If using SQLite (good for local development):
prisma
// api/db/schema.prisma datasource db { provider = "sqlite" url = env("DATABASE_URL") } // ... rest of the file
- Open
-
Define Database Schema: Add the
Appointmentmodel toapi/db/schema.prisma:prisma// api/db/schema.prisma model Appointment { id Int @id @default(autoincrement()) slotDateTime DateTime @db.Timestamptz(6) // PostgreSQL: Timezone-aware timestamp stored in UTC [9] phoneNumber String // User's phone number (E.164 format strongly recommended) [8] bookingCode String @unique // Unique code for cancellation/lookup confirmed Boolean @default(false) // Was booking confirmation SMS sent successfully? reminderSent Boolean @default(false) // Has the reminder SMS been sent successfully? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([slotDateTime]) // B-tree index for efficient time-based queries @@index([reminderSent, confirmed, slotDateTime]) // Composite index for reminder queries }Field explanations:
slotDateTime: Stores the exact date and time using PostgreSQL'sTIMESTAMPTZ(6)type, which stores timestamps in UTC with timezone awareness. This prevents timezone-related bugs in distributed systems.[9]phoneNumber: Stores the recipient number for SMS in E.164 format (+[country code][number], max 15 digits). E.164 is the international standard required by Vonage APIs.[8]bookingCode: A unique identifier generated during booking (8-character UUID substring).confirmed,reminderSent: Boolean flags to track SMS delivery status.- Indexing strategy: The single-column index on
slotDateTimeaccelerates booking availability checks. The composite index(reminderSent, confirmed, slotDateTime)optimizes the reminder query that filters by status flags and time range.[9]
-
Create and Apply Migration: This command creates SQL migration files based on your schema changes and applies them to your database.
bashyarn rw prisma migrate devEnter a name for the migration when prompted (e.g.,
add_appointment_model). -
Environment Variables (.env): RedwoodJS uses a
.envfile at the project root for environment variables. Create it if it doesn't exist and add your database connection string and Vonage credentials. Consider using.env.defaultsfor non-secret default values likeAPPOINTMENT_REMINDER_MINUTES_BEFORE.dotenv# .env # === Database === # Example for PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public" # Example for SQLite: DATABASE_URL="file:./dev.db" DATABASE_URL="YOUR_DATABASE_CONNECTION_STRING" # === Vonage API === # Get these from your Vonage Dashboard: https://dashboard.nexmo.com/settings VONAGE_API_KEY="YOUR_VONAGE_API_KEY" VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET" # This is a virtual number you purchase/rent from Vonage used to send SMS # Must be in E.164 format: +[country code][number] (e.g., +14155550100) VONAGE_FROM_NUMBER="YOUR_VONAGE_VIRTUAL_NUMBER" # === Application Settings === # Reminder time in minutes before the appointment APPOINTMENT_REMINDER_MINUTES_BEFORE="60" # === Security (Example for Cron Trigger) === # A secret shared between your scheduler and the function # Generate with: openssl rand -hex 32 # CRON_SECRET="YOUR_STRONG_RANDOM_SECRET"Security best practices for credentials:[10][11]
- Never commit secrets to version control: Add
.envto.gitignore(RedwoodJS does this by default). - Use different credentials per environment: Separate API keys for dev, staging, and production.
- Rotate credentials regularly: Vonage allows generating new API secrets in the dashboard. Plan quarterly rotations.
- Consider secrets management services: For production, use dedicated secrets management (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, or 1Password CLI) instead of environment variables. These services provide audit logs, automatic rotation, and fine-grained access control.[10][11]
- Limit credential permissions: Vonage API keys should have minimum required permissions.
- Production credential handling: When deploying to Vercel/Netlify, use their environment variable interfaces which encrypt secrets at rest.
- Never commit secrets to version control: Add
-
Initialize Vonage Client (API Side): Create a utility file to initialize the Vonage client instance using the v3.24.1 SDK.[4]
javascript// api/src/lib/vonage.js import { Vonage } from '@vonage/server-sdk' import { SMS } from '@vonage/messages' import { logger } from 'src/lib/logger' // Redwood's built-in logger let vonageInstance export const getVonageClient = () => { if (vonageInstance) { return vonageInstance } const apiKey = process.env.VONAGE_API_KEY const apiSecret = process.env.VONAGE_API_SECRET if (!apiKey || !apiSecret) { logger.error('Vonage API Key or Secret is missing in environment variables.') // In production, throw an error to prevent startup without credentials throw new Error('Vonage credentials not configured.') } try { // Using v3.24.1 SDK initialization vonageInstance = new Vonage({ apiKey: apiKey, apiSecret: apiSecret, // applicationId: 'YOUR_VONAGE_APPLICATION_ID', // Optional: Needed for Voice/Video APIs // privateKey: './private.key', // Optional: Needed for JWT-authenticated APIs }) logger.info('Vonage client initialized successfully (v3.24.1 SDK).') return vonageInstance } catch (error) { logger.error({ error }, 'Failed to initialize Vonage client') throw error // Re-throw error to prevent silent failures } } // Helper function for sending SMS using Vonage Messages API (v3 SDK) // Returns { success: boolean, messageId?: string, error?: string, details?: any } export const sendSms = async (to, text) => { const vonage = getVonageClient() // Throws if not configured const from = process.env.VONAGE_FROM_NUMBER if (!from || !to || !text) { const errorMsg = 'Cannot send SMS. Missing "from", "to", or "text".' logger.error(errorMsg) return { success: false, error: errorMsg } } // E.164 format validation: + optional, then 1-9, then 1-14 more digits [8] // Max length: 15 digits total. Country code: 1-3 digits. Subscriber number: remainder. if (!/^\+?[1-9]\d{1,14}$/.test(to) || !/^\+?[1-9]\d{1,14}$/.test(from)) { const errorMsg = `Invalid phone number format. 'to' or 'from' must be E.164 format. To: ${to}, From: ${from}` logger.error(errorMsg) return { success: false, error: errorMsg } } logger.info({ to, from }, 'Attempting to send SMS via Vonage Messages API') try { // Using v3 SDK Messages API // Vonage SDK includes automatic retry for transient network errors (3 attempts by default) const response = await vonage.messages.send(new SMS(text, to, from)) if (response.messages && response.messages[0]) { const messageId = response.messages[0]['message-id'] logger.info({ messageId, to }, 'SMS sent successfully') return { success: true, messageId } } else { logger.warn({ response }, 'SMS sent but unexpected response format') return { success: true, details: response } } } catch (error) { // Vonage API errors: insufficient balance (402), invalid number (422), rate limit (429) logger.error({ error, to, errorCode: error.statusCode }, 'Failed to send SMS via Vonage') return { success: false, error: error.message || 'Unknown error', details: error } } }Error handling notes:
- Throws errors during initialization if credentials are missing to fail fast.
sendSmsreturns a consistent{ success: boolean, ... }object for graceful error handling.- Vonage SDK v3 includes automatic retries for network errors (exponential backoff).
- Logs error codes for debugging common issues: 402 (insufficient credit), 422 (invalid number), 429 (rate limit).
2. Implementing Core Functionality (Booking)
Now, let's build the GraphQL API and the service logic for creating appointments.
-
Generate SDL and Service: Redwood's generators scaffold the necessary files.
bashyarn rw g sdl Appointment --crud # This creates api/src/graphql/appointments.sdl.js and api/src/services/appointments/appointments.js # It also generates basic CRUD operations. We will modify createAppointment. -
Define GraphQL Schema (SDL): Modify the generated
appointments.sdl.js(or.graphqlfile if preferred) to include a specific input type for creation and define thecreateAppointmentmutation.graphql# api/src/graphql/appointments.sdl.js or api/src/graphql/appointments.sdl.ts # If using .sdl.js/ts, ensure gql import is present: # import gql from 'graphql-tag' export const schema = gql` type Appointment { id: Int! slotDateTime: DateTime! phoneNumber: String! bookingCode: String! confirmed: Boolean! reminderSent: Boolean! createdAt: DateTime! updatedAt: DateTime! } # Input type for creating appointments input CreateAppointmentInput { slotDateTime: DateTime! # Expecting ISO 8601 String (UTC recommended) phoneNumber: String! # E.164 format strongly recommended } type Query { # Example query (optional for this guide) appointments: [Appointment!]! @requireAuth } type Mutation { # Mutation to create a new appointment createAppointment(input: CreateAppointmentInput!): Appointment! @skipAuth # We keep other generated mutations for now, but focus on create # updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth # deleteAppointment(id: Int!): Appointment! @requireAuth } `CreateAppointmentInput: Defines the data needed from the client (web side).createAppointment: The mutation the web side will call.@skipAuth: For simplicity in this guide, we bypass authentication. In a real app, you'd use@requireAuthand implement Redwood Auth (yarn rw setup auth ...).- Added an example
Queryblock, although not the focus here.
-
Implement the Service Logic: Update the
createAppointmentfunction inapi/src/services/appointments/appointments.js.javascript// api/src/services/appointments/appointments.js import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { sendSms } from 'src/lib/vonage' // Import our updated helper import { v4 as uuidv4 } from 'uuid' // For generating booking codes import { UserInputError } from '@redwoodjs/graphql-server' // For validation errors // Recommended: Add robust phone number validation // import { parsePhoneNumberFromString } from 'libphonenumber-js' export const appointments = () => { // Example implementation for the Query defined in SDL // Ensure proper authorization if using @requireAuth // import { requireAuth } from 'src/lib/auth' // requireAuth() return db.appointment.findMany() } export const createAppointment = async ({ input }) => { logger.info({ input }, 'Received request to create appointment') const { slotDateTime, phoneNumber } = input // --- 1. Input Validation --- if (!slotDateTime || !phoneNumber) { throw new UserInputError('Missing required fields: slotDateTime and phoneNumber') } // **Robust Phone Number Validation (Recommended)** // Use a library like libphonenumber-js for proper E.164 validation & formatting // yarn workspace api add libphonenumber-js // const parsedNumber = parsePhoneNumberFromString(phoneNumber) // if (!parsedNumber || !parsedNumber.isValid()) { // throw new UserInputError('Invalid phone number format. Please use E.164 format (e.g., +15551234567).') // } // const formattedPhoneNumber = parsedNumber.format('E.164') // Store normalized format // Basic validation (Use libphonenumber-js in production!) if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { throw new UserInputError('Invalid phone number format. Please use E.164 format (e.g., +15551234567).') } const formattedPhoneNumber = phoneNumber // Use validated/formatted number later let appointmentTime try { appointmentTime = new Date(slotDateTime) if (isNaN(appointmentTime.getTime())) { throw new Error('Invalid date format') } } catch (e) { throw new UserInputError('Invalid date format for slotDateTime. Please use ISO 8601 format.') } const now = new Date() // Ensure appointment is in the future (minimum 1 hour buffer) const minBookingTime = new Date(now.getTime() + 60 * 60 * 1000) // 1 hour from now if (appointmentTime <= minBookingTime) { throw new UserInputError('Appointment must be at least 1 hour in the future.') } // --- 2. Check Availability with Transaction for Race Condition Prevention --- // Prisma interactive transactions prevent race conditions in high-traffic scenarios // Alternative: Add a unique constraint on slotDateTime in schema for database-level enforcement try { const result = await db.$transaction(async (prisma) => { const existingAppointment = await prisma.appointment.findFirst({ where: { slotDateTime: appointmentTime }, }) if (existingAppointment) { throw new UserInputError('This appointment slot is already booked.') } // --- 3. Generate Booking Code --- // Using 8-char UUID substring: ~16^8 = 4.3B combinations // Collision probability acceptable for moderate volume (<100k appointments) // For high volume, use full UUID or database sequence const bookingCode = uuidv4().substring(0, 8).toUpperCase() // --- 4. Save to Database --- const newAppointment = await prisma.appointment.create({ data: { slotDateTime: appointmentTime, // Stored as UTC in TIMESTAMPTZ field phoneNumber: formattedPhoneNumber, bookingCode: bookingCode, confirmed: false, // Mark as not confirmed until SMS succeeds reminderSent: false, }, }) return newAppointment }) logger.info({ appointmentId: result.id, bookingCode: result.bookingCode }, 'Appointment saved to database') // --- 5. Send Confirmation SMS --- // Format time in user-friendly way (could accept timezone from client for localization) const confirmationText = `Your appointment is booked for ${result.slotDateTime.toLocaleString()}. Your booking code: ${result.bookingCode}.` const smsResult = await sendSms(result.phoneNumber, confirmationText) // --- 6. Update Confirmation Status Based on SMS Result --- if (smsResult.success) { logger.info({ appointmentId: result.id, messageId: smsResult.messageId }, 'Confirmation SMS sent successfully.') try { const updatedAppt = await db.appointment.update({ where: { id: result.id }, data: { confirmed: true }, }) result.confirmed = updatedAppt.confirmed logger.info({ appointmentId: result.id }, 'Appointment confirmed status updated in DB.') } catch (updateError) { logger.error({ updateError, appointmentId: result.id }, 'Failed to update confirmation status after SMS success.') } } else { logger.error({ appointmentId: result.id, error: smsResult.error }, 'Failed to send confirmation SMS.') // Options: 1) Return with confirmed:false (current), 2) Add to retry queue, 3) Delete appointment } return result } catch (error) { // Handle Prisma transaction errors and validation errors if (error instanceof UserInputError) { throw error } if (error.code === 'P2002' && error.meta?.target?.includes('bookingCode')) { logger.error('Booking code collision detected (very rare). Retry recommended.') throw new Error('Failed to generate unique booking code. Please try again.') } logger.error({ error }, 'Failed to create appointment') throw new Error('Failed to save appointment. Please try again.') } }Race condition handling:
- Uses Prisma
$transactionto wrap availability check and creation in a single database transaction. - This ensures atomic read-then-write, preventing double-booking even under concurrent requests.
- Alternative: Add
@@unique([slotDateTime])constraint in Prisma schema for database-level enforcement.
Error response structure for clients:
UserInputError: GraphQL returns400with{ errors: [{ message: "...", extensions: { code: "BAD_USER_INPUT" } }] }- Generic
Error: Returns500with{ errors: [{ message: "..." }] } - Client should check
error.graphQLErrors[0].extensions.codeto distinguish user errors from server errors.
- Uses Prisma
3. Building the Frontend (Web Side)
Let's create a simple React page with a form to book appointments.
-
Generate Page:
bashyarn rw g page AppointmentBooking /book # Creates web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js (and test/route files) -
Create the Form Component: Modify
web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js.javascript// web/src/pages/AppointmentBookingPage/AppointmentBookingPage.js import { useState } from 'react' import { MetaTags, useMutation } from '@redwoodjs/web' import { toast, Toaster } from '@redwoodjs/web/toast' // For notifications import { Form, TextField, DatetimeLocalField, Submit, Label, FieldError } from '@redwoodjs/forms' import gql from 'graphql-tag' // GraphQL Mutation matching the one defined in the SDL const CREATE_APPOINTMENT_MUTATION = gql` mutation CreateAppointmentMutation($input: CreateAppointmentInput!) { createAppointment(input: $input) { id slotDateTime phoneNumber bookingCode confirmed } } ` const AppointmentBookingPage = () => { const [formKey, setFormKey] = useState(Date.now()) // Used to reset form const [createAppointment, { loading, error }] = useMutation( CREATE_APPOINTMENT_MUTATION, { onCompleted: (data) => { if (data.createAppointment.confirmed) { toast.success( `Appointment booked successfully! Code: ${data.createAppointment.bookingCode}. Check your phone for confirmation.`, { duration: 8000 } ) } else { // Use warning toast if confirmation failed but booking succeeded toast.warn( `Appointment booked (Code: ${data.createAppointment.bookingCode}), but confirmation SMS failed. Please contact support.`, { duration: 10000 } ) } // Reset form by changing the key prop setFormKey(Date.now()) }, onError: (error) => { // Check for user input errors vs server errors const isUserError = error.graphQLErrors?.some( err => err.extensions?.code === 'BAD_USER_INPUT' ) if (isUserError) { toast.error(`Booking failed: ${error.message}`) } else { toast.error('Server error. Please try again or contact support.') } }, } ) const onSubmit = (formData) => { // Convert datetime-local (browser local time) to ISO 8601 UTC string const input = { slotDateTime: new Date(formData.slotDateTime).toISOString(), phoneNumber: formData.phoneNumber, } createAppointment({ variables: { input } }) } // Tailwind CSS classes const labelStyle = "block text-sm font-medium text-gray-700 mb-1" const inputStyle = "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" const errorStyle = "mt-1 text-xs text-red-600" // Calculate minimum datetime: 1 hour from now const getMinDateTime = () => { const now = new Date() now.setHours(now.getHours() + 1) return now.toISOString().slice(0, 16) // Format: YYYY-MM-DDTHH:mm } return ( <> <MetaTags title="Book Appointment" description="Book your appointment slot" /> <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} /> <div className="max-w-md mx-auto mt-8 p-6 bg-white rounded-lg shadow-md" role="main"> <h1 className="text-2xl font-semibold mb-4 text-center">Book Your Appointment</h1> <Form key={formKey} onSubmit={onSubmit} config={{ mode: 'onBlur' }}> <div className="mb-4"> <Label name="slotDateTime" className={labelStyle} errorClassName={`${labelStyle} text-red-700`} > Choose date and time: </Label> <DatetimeLocalField name="slotDateTime" className={inputStyle} errorClassName={`${inputStyle} border-red-500`} validation={{ required: 'Appointment date and time is required', valueAsDate: true }} min={getMinDateTime()} aria-describedby="datetime-help" /> <p id="datetime-help" className="mt-1 text-xs text-gray-500"> Must be at least 1 hour in the future </p> <FieldError name="slotDateTime" className={errorStyle} /> </div> <div className="mb-4"> <Label name="phoneNumber" className={labelStyle} errorClassName={`${labelStyle} text-red-700`} > Phone number: </Label> <TextField name="phoneNumber" className={inputStyle} errorClassName={`${inputStyle} border-red-500`} validation={{ required: 'Phone number is required', pattern: { value: /^\+?[1-9]\d{1,14}$/, message: 'Use E.164 format (e.g., +15551234567)' } }} placeholder="+15551234567" aria-describedby="phone-help" /> <p id="phone-help" className="mt-1 text-xs text-gray-500"> International format: +[country code][number] </p> <FieldError name="phoneNumber" className={errorStyle} /> </div> {error && ( <div className="mb-4 p-3 bg-red-100 text-red-700 rounded" role="alert"> Error: {error.message} </div> )} <Submit disabled={loading} className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors" aria-label={loading ? 'Booking in progress' : 'Book appointment'} > {loading ? 'Booking...' : 'Book Appointment'} </Submit> </Form> </div> </> ) } export default AppointmentBookingPageForm validation strategy:
- Client-side validation with React Hook Form (via RedwoodJS Forms) provides immediate feedback.
onBlurmode validates fields when user leaves the field.- Server-side validation in GraphQL service is the authoritative check.
- User feedback via toast notifications distinguishes between user errors and system errors.
Accessibility considerations:
role="main"landmark for main content.aria-describedbylinks help text to form fields.aria-labelprovides context for submit button states.role="alert"announces errors to screen readers.- Keyboard navigation fully supported (Tab, Enter).
-
Run the Development Server:
bashyarn rw devNavigate to
http://localhost:8910/book(or the port specified). You should see the form. Try booking an appointment. Check your terminal logs (apiside) and your phone for the SMS confirmation! Check the database to see theconfirmedflag.
4. Implementing Scheduled Reminders
We need a mechanism to periodically check for upcoming appointments and send reminders. We'll use a RedwoodJS Function triggered by an external scheduler. Running cron within a serverless function is unreliable because serverless instances are ephemeral.
Scheduling Approaches Comparison:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| Vercel Cron Jobs | Built-in, no extra infrastructure, free tier available | Vercel-only, limited to production deployments | Vercel-hosted apps |
| Netlify Scheduled Functions | Native integration, simple configuration | Netlify-only, beta feature, 30s timeout | Netlify-hosted apps |
| System Cron (Linux/macOS) | Full control, reliable, no vendor lock-in | Requires server access, manual setup | Self-hosted or VPS deployments |
| GitHub Actions Schedule | Free for public repos, CI/CD integration | Minimum 5-minute intervals, rate limits | Open-source projects |
| RedwoodJS Background Jobs | Native RedwoodJS feature, built-in scheduling | Requires v8.0+, needs dedicated worker process | Complex job queues |
Recommended: Use platform-native solutions (Vercel Cron or Netlify Scheduled Functions) for simplicity, or system cron for maximum reliability.[6][7]
-
Create a Redwood Function:
bashyarn rw g function sendReminders # Creates api/src/functions/sendReminders.js -
Implement the Function Logic: Edit
api/src/functions/sendReminders.js:javascript// api/src/functions/sendReminders/sendReminders.js import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { sendSms } from 'src/lib/vonage' import { add } from 'date-fns' export const handler = async (event, _context) => { logger.info('sendReminders function triggered') // Security: Verify cron secret to prevent unauthorized invocations const cronSecret = process.env.CRON_SECRET if (cronSecret) { const providedSecret = event.headers['x-cron-secret'] || event.queryStringParameters?.secret if (providedSecret !== cronSecret) { logger.warn('Unauthorized attempt to trigger sendReminders function') return { statusCode: 401, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Unauthorized' }), } } } try { const reminderMinutes = parseInt(process.env.APPOINTMENT_REMINDER_MINUTES_BEFORE || '60', 10) const now = new Date() const reminderWindow = add(now, { minutes: reminderMinutes }) // Find appointments needing reminders: not sent, confirmed, within reminder window const appointmentsToRemind = await db.appointment.findMany({ where: { reminderSent: false, confirmed: true, slotDateTime: { lte: reminderWindow, gte: now, }, }, }) logger.info(`Found ${appointmentsToRemind.length} appointments needing reminders`) let successCount = 0 let failureCount = 0 const failedAppointments = [] // Process reminders sequentially to avoid overwhelming SMS API rate limits // For high volume (>100 reminders), implement batching with delays for (const appointment of appointmentsToRemind) { const reminderText = `Reminder: Your appointment is scheduled for ${appointment.slotDateTime.toLocaleString()}. Booking code: ${appointment.bookingCode}` const smsResult = await sendSms(appointment.phoneNumber, reminderText) if (smsResult.success) { // Mark as sent immediately to prevent duplicate sends if function times out await db.appointment.update({ where: { id: appointment.id }, data: { reminderSent: true }, }) successCount++ logger.info({ appointmentId: appointment.id }, 'Reminder sent successfully') } else { failureCount++ failedAppointments.push({ id: appointment.id, error: smsResult.error }) logger.error({ appointmentId: appointment.id, error: smsResult.error }, 'Failed to send reminder') // Retry strategy: Failed reminders remain with reminderSent:false // Next cron run will retry (idempotent) } } const response = { message: 'Reminder processing complete', total: appointmentsToRemind.length, success: successCount, failed: failureCount, failedAppointments: failedAppointments.length > 0 ? failedAppointments : undefined, } return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(response), } } catch (error) { logger.error({ error }, 'Error in sendReminders function') return { statusCode: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Internal server error', details: error.message }), } } }Retry logic and failure recovery:
- Failed reminders keep
reminderSent: false, so next cron run retries automatically (idempotent). - Successful sends mark
reminderSent: trueimmediately to prevent duplicates if function times out. - Sequential processing prevents rate limit issues. For high volume (>100/run), add
await new Promise(r => setTimeout(r, 100))between sends. - Consider: Implement exponential backoff or dead-letter queue for persistent failures after 3 attempts.
- Failed reminders keep
-
Configure External Scheduler:
Option 1: Vercel Cron Jobs[6]
Create or edit
vercel.jsonin project root:json{ "crons": [{ "path": "/.redwood/functions/sendReminders?secret=YOUR_CRON_SECRET", "schedule": "*/15 * * * *" }] }schedule: Cron expression.*/15 * * * *= every 15 minutes.- Runs only in production. Preview deployments ignore cron jobs.
- Free tier: Up to 1 cron job, 100 invocations/day.
- Vercel automatically passes request headers. No additional auth needed if using secret in query parameter.
Vercel Setup Steps:
- Deploy to Vercel:
yarn rw setup deploy vercelthenyarn rw deploy vercel - Add
CRON_SECRETenvironment variable in Vercel dashboard (Settings → Environment Variables) - Add
vercel.jsonto project root and redeploy - Monitor: Vercel dashboard → Functions → sendReminders → Logs
Option 2: Netlify Scheduled Functions[7]
Edit
api/src/functions/sendReminders.jsto add config export:javascript// At the top of api/src/functions/sendReminders.js import type { Config } from "@netlify/functions" export const config: Config = { schedule: "@hourly" // Or "*/15 * * * *" for every 15 minutes } // ... rest of handler codeAlternatively, use
netlify.toml:toml# netlify.toml [functions."sendReminders"] schedule = "*/15 * * * *"Netlify Setup Steps:
- Deploy to Netlify:
yarn rw setup deploy netlifythenyarn rw deploy netlify - Add
CRON_SECRETenvironment variable in Netlify dashboard (Site settings → Environment variables) - Scheduled functions only run on published deploys (not deploy previews)
- Monitor: Netlify dashboard → Functions → sendReminders → Logs
- Limitation: 30-second execution timeout (vs 10s for regular functions)
Option 3: System Cron (Linux/macOS)
Edit crontab:
crontab -ebash# Send reminders every 15 minutes */15 * * * * curl -X GET https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET" >> /var/log/appointment-reminders.log 2>&1- Requires server with persistent cron daemon
- Most reliable for production workloads
- Use
@rebootentry to ensure cron survives server restarts - Monitor logs:
tail -f /var/log/appointment-reminders.log
Option 4: GitHub Actions (for testing/open-source)
Create
.github/workflows/send-reminders.yml:yamlname: Send Appointment Reminders on: schedule: - cron: '*/15 * * * *' # Every 15 minutes workflow_dispatch: # Allow manual trigger jobs: send-reminders: runs-on: ubuntu-latest steps: - name: Trigger reminder function run: | curl -X GET https://your-app.com/.redwood/functions/sendReminders \ -H "x-cron-secret: ${{ secrets.CRON_SECRET }}"- Minimum interval: 5 minutes
- May have ~10 minute delay in execution
- Free for public repositories
5. Deployment Considerations
Deploy your RedwoodJS application to platforms like Vercel, Netlify, or Render.
Key Environment Variables:
DATABASE_URL: Production database connection string (use connection pooling for serverless: PgBouncer, Supabase, or Neon)VONAGE_API_KEY: Your Vonage API keyVONAGE_API_SECRET: Your Vonage API secretVONAGE_FROM_NUMBER: Your Vonage virtual number (E.164 format)APPOINTMENT_REMINDER_MINUTES_BEFORE: Reminder timing (default: 60)CRON_SECRET: Secret for securing cron endpoint (generate withopenssl rand -hex 32)
Database Migrations: Run migrations in production:
yarn rw prisma migrate deployPlatform-Specific Deployment Guides:
Vercel Deployment:
- Run setup:
yarn rw setup deploy vercel - Install Vercel CLI:
npm i -g vercel - Deploy:
yarn rw deploy vercel - Add environment variables in Vercel dashboard: Settings → Environment Variables
- Ensure
vercel.jsonincludes cron configuration (see Section 4) - Redwood auto-configures serverless functions and API routes
- Production URL:
https://your-app.vercel.app
Netlify Deployment:
- Run setup:
yarn rw setup deploy netlify - Install Netlify CLI:
npm i -g netlify-cli - Deploy:
yarn rw deploy netlify - Add environment variables: Site settings → Environment variables → Add a variable
- Configure scheduled function (see Section 4)
- Netlify auto-deploys from Git on push to main branch
- Production URL:
https://your-app.netlify.app
Render Deployment:
- Create Render account at https://render.com
- New → Web Service → Connect repository
- Build command:
yarn install && yarn rw build - Start command:
yarn rw serve - Add environment variables in dashboard
- For cron: Create separate "Cron Job" service pointing to sendReminders function URL
Security Best Practices:[10][11]
-
Never commit
.envfiles: Ensure.gitignoreincludes.env(RedwoodJS default) -
Environment-specific configurations: Use different API keys for dev, staging, production
-
Secrets management services: For production, migrate from environment variables to dedicated secrets managers:
- AWS Secrets Manager: Auto-rotation, audit logs, fine-grained IAM policies
- Google Secret Manager: Integrated with GCP IAM, versioning, automatic encryption
- HashiCorp Vault: Self-hosted, dynamic secrets, leasing, revocation
- 1Password CLI: Developer-friendly, integrates with CI/CD, team vaults
- Doppler: Modern secrets management SaaS, environment branching
Example with 1Password CLI:
bash# .env references 1Password VONAGE_API_KEY="op://Vault/Vonage/api_key" # Start app with secrets injection op run -- yarn rw dev -
Implement rate limiting: Use RedwoodJS middleware or API gateway (Cloudflare, AWS API Gateway) to limit requests/IP
-
Add authentication:
yarn rw setup auththen choose provider (Auth0, Supabase, Clerk). Replace@skipAuthwith@requireAuth -
Input validation: Always validate on server-side. Client validation is convenience only.
-
HTTPS in production: Enforce HTTPS redirects (Vercel/Netlify automatic, Render: add HTTPS redirect rule)
-
Credential rotation: Plan quarterly rotation of Vonage API keys and database passwords
-
Monitor logs: Never log full
process.envor secrets. Redwood's logger redacts sensitive fields by default. -
CRON_SECRET security: Treat as sensitive. Rotate if exposed. Consider IP allowlisting if possible.
6. Testing Your Application
Test the Booking Flow:
- Navigate to
/bookin your browser - Select a future date and time (at least 1 hour ahead)
- Enter your phone number in E.164 format (e.g., +15551234567)
- Submit the form
- Check your phone for the confirmation SMS within 5-10 seconds
- Verify the appointment appears in your database:
bash
yarn rw prisma studio - Check that
confirmed: trueif SMS was successful
Test the Reminder System:
- Create a test appointment 1 hour in the future (or adjust
APPOINTMENT_REMINDER_MINUTES_BEFOREto 5 minutes for quick testing) - Manually trigger the function:
bash
# Local testing curl -X GET http://localhost:8911/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET" # Production testing curl -X GET https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_CRON_SECRET" - Check your phone for the reminder SMS
- Verify
reminderSent: truein database
Automated Testing Strategy:
RedwoodJS includes Jest for unit and integration testing.
Service Tests (api/src/services/appointments/appointments.test.js):
import { db } from 'src/lib/db'
import { createAppointment } from './appointments'
// Mock Vonage SMS
jest.mock('src/lib/vonage', () => ({
sendSms: jest.fn(() => Promise.resolve({ success: true, messageId: 'test-123' }))
}))
describe('createAppointment', () => {
scenario('creates appointment with valid data', async (scenario) => {
const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000) // 2 hours ahead
const input = {
slotDateTime: futureDate.toISOString(),
phoneNumber: '+15551234567'
}
const result = await createAppointment({ input })
expect(result.phoneNumber).toBe('+15551234567')
expect(result.confirmed).toBe(true)
expect(result.bookingCode).toHaveLength(8)
})
scenario('rejects duplicate time slots', async () => {
const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000)
await createAppointment({
input: { slotDateTime: futureDate.toISOString(), phoneNumber: '+15551234567' }
})
await expect(
createAppointment({
input: { slotDateTime: futureDate.toISOString(), phoneNumber: '+15559876543' }
})
).rejects.toThrow('already booked')
})
scenario('rejects past appointments', async () => {
const pastDate = new Date(Date.now() - 1000)
await expect(
createAppointment({
input: { slotDateTime: pastDate.toISOString(), phoneNumber: '+15551234567' }
})
).rejects.toThrow('must be at least 1 hour in the future')
})
scenario('rejects invalid phone numbers', async () => {
const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000)
await expect(
createAppointment({
input: { slotDateTime: futureDate.toISOString(), phoneNumber: '555-1234' }
})
).rejects.toThrow('E.164 format')
})
})Run tests:
yarn rw test apiCommon Test Scenarios:
- ✅ Booking valid appointments (future time, E.164 number)
- ❌ Booking past appointments (should fail validation)
- ❌ Booking duplicate time slots (should fail with UserInputError)
- ❌ Invalid phone number formats (should fail with validation error)
- ❌ Missing required fields (should fail)
- ✅ SMS delivery success updates
confirmed: true - ❌ SMS delivery failures keep
confirmed: falseand log error - ✅ Reminder function finds and sends to eligible appointments
- ✅ Reminder function marks
reminderSent: trueafter success
7. Troubleshooting Common Issues
SMS Not Sending:
- Verify credentials: Check
VONAGE_API_KEY,VONAGE_API_SECRET, andVONAGE_FROM_NUMBERin.envbash# Test credentials node -e "console.log(process.env.VONAGE_API_KEY)" - Phone number format: Must be E.164 (
+[country][number], no spaces). Test with regex:^\+?[1-9]\d{1,14}$[8] - Virtual number capabilities: Verify number has SMS capability in Vonage Dashboard → Numbers
- API errors: Check Vonage Dashboard → Reports → SMS logs for error codes:
0: Success1: Throttled (rate limit)2: Missing parameters3: Invalid parameters4: Invalid credentials5: Internal error6: Invalid message7: Number barred8: Partner account barred9: Partner quota exceeded
- Account balance: Check Vonage Dashboard → Billing. SMS costs $0.0075-$0.0150 per message (US)
- Geographic restrictions: Some countries require sender registration (10DLC in US, A2P in India)
Database Connection Errors:
- Connection string: Verify
DATABASE_URLformatbash# PostgreSQL format postgresql://user:password@host:5432/database?schema=public # Test connection yarn rw prisma db push - Database running: Ensure PostgreSQL server is active
bash
# macOS brew services list | grep postgresql # Linux systemctl status postgresql - Firewall/network: Check if database port (5432 for PostgreSQL) is accessible
bash
telnet your-db-host.com 5432 - Schema out of sync: Run migrations
bash
yarn rw prisma migrate dev # Or reset for clean slate (DELETES ALL DATA) yarn rw prisma migrate reset - Connection pooling: For serverless deployments, use connection pooling (PgBouncer, Supabase, or Neon) to prevent exhausting connections
Reminder Function Not Triggering:
- Cron configuration: Verify
vercel.jsonornetlify.tomlsyntaxbash# Test cron expression at crontab.guru */15 * * * * # Every 15 minutes - CRON_SECRET mismatch: Ensure secret matches in environment and cron request
bash
# Check environment variable echo $CRON_SECRET # Test manually curl -v https://your-app.com/.redwood/functions/sendReminders -H "x-cron-secret: YOUR_SECRET" - Function logs: Check platform logs:
- Vercel: Dashboard → Functions → sendReminders → Logs
- Netlify: Dashboard → Functions → sendReminders → Function log
- System cron:
tail -f /var/log/cron.log
- Reminder window logic: Verify
APPOINTMENT_REMINDER_MINUTES_BEFOREcalculationjavascript// Debug in function logger.info({ now, reminderWindow, reminderMinutes }, 'Reminder window calculated') - Appointments exist: Query database for eligible appointments
bash
yarn rw prisma studio # Filter: reminderSent = false, confirmed = true, slotDateTime within window
GraphQL Mutation Failures:
- SDL schema mismatch: Ensure
appointments.sdl.jstypes match service return valuesbashyarn rw dev # Open GraphQL Playground: http://localhost:8911/graphql - Input validation: Check server logs for validation errors
bash
# API logs show in terminal running `yarn rw dev` - Test in GraphQL Playground:
graphql
mutation CreateTest { createAppointment(input: { slotDateTime: "2025-10-15T14:00:00Z" phoneNumber: "+15551234567" }) { id bookingCode confirmed } } - CORS issues: RedwoodJS configures CORS automatically. If issues persist, check
api/src/functions/graphql.js
Frequently Asked Questions
How do I change the reminder timing for appointments?
Adjust the APPOINTMENT_REMINDER_MINUTES_BEFORE environment variable in your .env file (or hosting platform environment config). Set it to 60 for 1 hour, 1440 for 24 hours, or any value in minutes. The sendReminders function uses this value to calculate the reminder window:
// Reminder sent when: now < appointment_time <= (now + APPOINTMENT_REMINDER_MINUTES_BEFORE)
const reminderWindow = add(now, { minutes: parseInt(process.env.APPOINTMENT_REMINDER_MINUTES_BEFORE) })For multiple reminder times (e.g., 24 hours AND 1 hour before), add a remindersSent JSON field to track which reminders were sent, and modify the function logic accordingly.
Can I send reminders through WhatsApp instead of SMS?
Yes. Vonage supports WhatsApp messaging through the Messages API. Modifications required:[4]
- Set up WhatsApp Business Account with Vonage (requires approval process)
- Replace SMS class in
sendSmsfunction:javascriptimport { WhatsApp } from '@vonage/messages' // Instead of: new SMS(text, to, from) const message = new WhatsApp({ to: to, from: from, // Your WhatsApp Business Number message_type: 'text', text: text }) const response = await vonage.messages.send(message) - Update phone number requirements: WhatsApp requires users to opt-in first. Store opt-in status in database.
- Message templates: WhatsApp enforces message templates for business-initiated messages. Register templates in Vonage Dashboard.
Reference: Vonage WhatsApp API Documentation
How do I prevent double-booking appointments?
The createAppointment service uses Prisma transactions to prevent race conditions:[9]
await db.$transaction(async (prisma) => {
const existingAppointment = await prisma.appointment.findFirst({
where: { slotDateTime: appointmentTime },
})
if (existingAppointment) throw new UserInputError('Slot already booked')
const newAppointment = await prisma.appointment.create({ data: {...} })
return newAppointment
})Alternative: Database-level constraint:
model Appointment {
// ... other fields
slotDateTime DateTime @db.Timestamptz(6)
@@unique([slotDateTime]) // Prevents duplicates at database level
}Then run migration: yarn rw prisma migrate dev
The database constraint is more robust for high-traffic scenarios, but throws less user-friendly errors.
What phone number format should users enter?
Users should enter phone numbers in E.164 format: +[country code][number] with no spaces or special characters.[8]
Examples:
- US:
+14155551234(country code 1, area code 415, number 5551234) - UK:
+442071234567(country code 44, area code 20, number 71234567) - Spain:
+34912345678(country code 34, area code 91, number 2345678)
E.164 specification:
- Starts with
+(optional in code, required by Vonage) - Country code: 1-3 digits (mandatory)
- Subscriber number: remaining digits
- Total maximum: 15 digits
Recommended: Use libphonenumber-js for robust validation:
yarn workspace api add libphonenumber-jsimport { parsePhoneNumberFromString } from 'libphonenumber-js'
const parsedNumber = parsePhoneNumberFromString(phoneNumber, 'US') // Default country hint
if (!parsedNumber || !parsedNumber.isValid()) {
throw new UserInputError('Invalid phone number')
}
const formattedPhoneNumber = parsedNumber.format('E.164') // Normalize to +14155551234Client-side: Add country selector dropdown with react-phone-number-input for better UX.
How do I add user authentication to the booking system?
RedwoodJS supports multiple authentication providers through RedwoodJS Auth:[1]
Setup steps:
# Choose a provider: dbAuth (built-in), Auth0, Supabase, Clerk, Firebase, etc.
yarn rw setup auth dbAuth
# Generates: auth functions, login/signup pages, database migrationsUpdate SDL to require auth:
type Mutation {
createAppointment(input: CreateAppointmentInput!): Appointment! @requireAuth # Changed from @skipAuth
}Use auth in services:
import { requireAuth } from 'src/lib/auth'
export const createAppointment = async ({ input }) => {
requireAuth() // Throws error if not authenticated
const userId = context.currentUser.id // Access logged-in user
// Associate appointment with user
const newAppointment = await db.appointment.create({
data: {
...input,
userId: userId // Add userId field to schema
}
})
}Frontend: Use useAuth hook:
import { useAuth } from '@redwoodjs/auth'
const { isAuthenticated, currentUser, logIn, logOut } = useAuth()
if (!isAuthenticated) {
return <Redirect to="/login" />
}For user-specific appointment views, add userId to Appointment model and filter queries by currentUser.id.
Can I customize the SMS message content?
Yes. Modify the confirmationText and reminderText in your code:
Confirmation SMS (in appointments.js service):
const confirmationText = `✓ Appointment confirmed for ${result.slotDateTime.toLocaleString()}. Code: ${result.bookingCode}. Reply CANCEL to cancel.`Reminder SMS (in sendReminders.js function):
const reminderText = `⏰ Reminder: Appointment in ${reminderMinutes} minutes (${appointment.slotDateTime.toLocaleString()}). Code: ${appointment.bookingCode}. See you soon!`Best practices:
- Keep under 160 characters to avoid multi-part SMS charges (each part costs separately)
- GSM-7 character set (no emoji) = 160 chars. Unicode (with emoji) = 70 chars per segment.
- Include: appointment time, booking code, clear call-to-action
- Consider timezone: Convert UTC to user's local time for display
- Comply with TCPA regulations (US): Include opt-out instructions, identify sender
How do I handle different time zones for appointments?
Best practice: Store in UTC, display in local time.[9]
Database storage:
model Appointment {
slotDateTime DateTime @db.Timestamptz(6) // PostgreSQL timezone-aware type [9]
// Stores as UTC internally, converts to local on retrieval
}Client-side: Convert to local time:
// Option 1: Native JavaScript
const localTime = new Date(appointment.slotDateTime).toLocaleString('en-US', {
timeZone: 'America/New_York', // User's timezone from client or profile
dateStyle: 'medium',
timeStyle: 'short'
})
// Option 2: date-fns-tz library
import { formatInTimeZone } from 'date-fns-tz'
const localTime = formatInTimeZone(
appointment.slotDateTime,
'America/New_York',
'MMM d, yyyy h:mm a zzz'
)
// Output: "Oct 15, 2025 2:00 PM EDT"Server-side: Accept timezone from client:
// Modified input schema
input CreateAppointmentInput {
slotDateTime: DateTime!
phoneNumber: String!
timezone: String # e.g., "America/New_York" (IANA timezone)
}
// Service: Store timezone for SMS formatting
const newAppointment = await db.appointment.create({
data: {
slotDateTime: appointmentTime, // Prisma converts to UTC
timezone: input.timezone || 'UTC', // Add timezone field to schema
...
}
})
// SMS: Format in user's timezone
import { formatInTimeZone } from 'date-fns-tz'
const localTimeStr = formatInTimeZone(
appointment.slotDateTime,
appointment.timezone,
'MMM d, yyyy h:mm a zzz'
)
const reminderText = `Reminder: Appointment at ${localTimeStr}. Code: ${appointment.bookingCode}`Key principles:
- Always store timestamps in UTC (Prisma's
DateTimewith@db.Timestamptzdoes this automatically) - Pass user's timezone from client (detect with
Intl.DateTimeFormat().resolvedOptions().timeZone) - Convert to local time only for display purposes (UI, SMS)
- Never perform date math in local time; always use UTC for calculations
What happens if SMS delivery fails?
The sendSms function returns a { success: boolean } object. On failure:
-
Booking confirmation failure:
- Appointment is created with
confirmed: false - User sees warning toast: "Appointment booked but SMS failed"
- Admin should monitor appointments with
confirmed: falseand contact users manually
- Appointment is created with
-
Reminder failure:
reminderSentremainsfalse- Next cron run will retry automatically (idempotent)
- After 3 failed attempts, consider moving to dead-letter queue
Implementing a retry queue with RedwoodJS Background Jobs:[1]
yarn rw setup jobs// api/src/jobs/SendReminderJob/SendReminderJob.js
export const SendReminderJob = async ({ appointmentId }) => {
const appointment = await db.appointment.findUnique({ where: { id: appointmentId } })
const result = await sendSms(appointment.phoneNumber, `Reminder: ...`)
if (result.success) {
await db.appointment.update({ where: { id: appointmentId }, data: { reminderSent: true } })
} else {
throw new Error('SMS failed') // Job system will retry with exponential backoff
}
}
// In sendReminders function: Queue failed reminders
if (!smsResult.success) {
await scheduleJob('SendReminderJob', { appointmentId: appointment.id }, {
runAt: new Date(Date.now() + 5 * 60 * 1000), // Retry in 5 minutes
maxAttempts: 3
})
}Always log failures for monitoring and debugging:
logger.error({
appointmentId,
phoneNumber,
error: smsResult.error,
vonageErrorCode: smsResult.details?.statusCode
}, 'SMS delivery failed')Set up alerts (Sentry, Datadog, or simple email on error) to notify admins of persistent failures.
How much does it cost to send SMS through Vonage?
Vonage pricing is per-message, varying by destination country:[4]
Common rates (as of 2025):
- US/Canada: $0.0075 - $0.0150 per SMS
- UK: $0.0353 per SMS
- Most of Europe: $0.08 - $0.15 per SMS
- India: $0.0069 per SMS
- Australia: $0.076 per SMS
Multi-part messages:
- Standard SMS: 160 characters (GSM-7) or 70 characters (Unicode/emoji)
- Longer messages split into multiple parts, each charged separately
- Example: 180-character message = 2 SMS charges
Cost estimation:
- 1000 appointments/month × 2 SMS (confirmation + reminder) = 2000 SMS
- At $0.01/SMS (average US rate) = $20/month
New accounts: Receive free trial credits ($2-10 depending on region) for testing.
Cost optimization:
- Keep messages under 160 characters (GSM-7) to avoid multi-part charges
- Use unicode sparingly (emoji reduces limit to 70 chars)
- Consider WhatsApp Business API for high volume (cheaper per message, but requires setup)
- Monitor usage: Vonage Dashboard → Reports → SMS Usage
Check current rates: Vonage SMS Pricing
How do I deploy this application to production?
Recommended platforms for RedwoodJS:
1. Vercel (Easiest):
# Setup
yarn rw setup deploy vercel
# Deploy
yarn rw deploy vercel --prod- Auto-deploys from Git on push to main branch
- Add environment variables: Vercel Dashboard → Settings → Environment Variables
- Configure cron jobs in
vercel.json(see Section 4) - Free tier: 100GB bandwidth, 100 serverless function invocations/day
2. Netlify:
# Setup
yarn rw setup deploy netlify
# Deploy
yarn rw deploy netlify --prod- Connect Git repository for auto-deploys
- Add environment variables: Site settings → Environment variables
- Configure scheduled functions (see Section 4)
- Free tier: 100GB bandwidth, 125k serverless requests/month
3. Render:
- Create account at render.com
- New → Web Service → Connect repository
- Build command:
yarn install && yarn rw build - Start command:
yarn rw serve - Add environment variables in dashboard
- Create separate Cron Job service for reminders
- Free tier: 750 hours/month, sleeps after 15min inactivity
Pre-deployment checklist:
- ✅ Run
yarn rw buildlocally to verify build succeeds - ✅ Add all environment variables to hosting platform
- ✅ Run
yarn rw prisma migrate deployafter deployment - ✅ Test production API:
curl https://your-app.com/.redwood/functions/sendReminders - ✅ Verify cron jobs are configured and running
- ✅ Check database connection (use connection pooling for serverless)
- ✅ Monitor logs for first 24 hours after deployment
- ✅ Test SMS sending with real phone number
- ✅ Set up error monitoring (Sentry, Bugsnag, or LogRocket)
Production monitoring:
- Vonage Dashboard: Monitor SMS delivery rates, error codes, account balance
- Platform logs: Check function execution logs for errors
- Database: Monitor connection pool usage, slow queries
- Uptime monitoring: Use UptimeRobot or Pingdom to alert on downtime
References
[1] RedwoodJS. "Releases." GitHub and RedwoodJS Community. Retrieved from https://github.com/redwoodjs/redwood/releases and https://community.redwoodjs.com/
[2] Node.js Release Working Group. "Node.js Releases." Retrieved from https://nodejs.org/en/about/previous-releases and https://nodesource.com/blog/nodejs-v22-long-term-support-lts
[3] Prisma. "Changelog and Releases." Retrieved from https://www.prisma.io/changelog and https://github.com/prisma/prisma/releases
[4] Vonage. "@vonage/server-sdk." npm. Retrieved from https://www.npmjs.com/package/@vonage/server-sdk and https://developer.vonage.com/
[5] Healthcare IT News. "No-show rates cost healthcare billions annually." 2024. Statistical data on appointment no-show impacts.
[6] Vercel. "Cron Jobs Documentation." Retrieved from https://vercel.com/docs/cron-jobs and https://vercel.com/guides/how-to-setup-cron-jobs-on-vercel
[7] Netlify. "Scheduled Functions Documentation." Retrieved from https://docs.netlify.com/build/functions/scheduled-functions
[8] Vonage. "What Is E.164 Format?" Developer Blog. Retrieved October 2024 from https://developer.vonage.com/en/blog/what-is-e-164-format
[9] Deiaa, Basem. "How to Fix Prisma DateTime and Timezone Issues with PostgreSQL." Medium. September 2025. Retrieved from https://medium.com/@basem.deiaa/how-to-fix-prisma-datetime-and-timezone-issues-with-postgresql-1c778aa2d122
[10] Liran Tal. "Do not use secrets in environment variables." Node.js Security Blog. October 2024. Retrieved from https://nodejs-security.com/blog/do-not-use-secrets-in-environment-variables
[11] FullStack Labs. "Best Practices for Scalable & Secure React + Node.js Apps in 2025." July 2025. Retrieved from https://www.fullstack.com/labs/resources/blog/best-practices-for-scalable-secure-react-node-js-apps-in-2025