How to Build SMS Appointment Reminders with RedwoodJS and MessageBird (2025)
Complete step-by-step guide to building automated SMS appointment reminder systems with RedwoodJS and MessageBird API. Includes phone validation, scheduling, GraphQL integration, and production deployment.
Build a production-ready appointment booking system with automated SMS reminders using RedwoodJS and MessageBird. This comprehensive tutorial covers everything from project setup and database design to phone validation and deployment – perfect for developers creating appointment-based applications with SMS notifications.
Modern appointment booking systems require reliable SMS notifications to reduce no-shows and improve customer experience. This guide shows you how to leverage RedwoodJS's full-stack capabilities with MessageBird's SMS API to create an automated reminder system that validates phone numbers, schedules messages, and manages appointments efficiently.
How to Build an SMS Appointment Reminder System with RedwoodJS and MessageBird
Create a complete appointment booking application with automated SMS notifications using these production-ready features:
User booking interface – React-based form where customers book appointments with name, service type, phone number, and preferred time
Phone number validation – Real-time verification using MessageBird's Lookup API to ensure mobile numbers are valid and reachable
Automated SMS reminders – Scheduled messages sent via MessageBird Messages API at predefined intervals (e.g., 3 hours before appointments)
Database persistence – Prisma ORM integration for storing appointments, reminder schedules, and message tracking
Confirmation flow – User feedback after successful bookings with appointment details
Note: This tutorial focuses on core booking and reminder functionality. Appointment updates and cancellations (including MessageBird message management) require additional implementation for production systems.
Why Build This Appointment Reminder System:
Automated SMS reminders reduce appointment no-shows significantly, with studies showing reductions between 5-38% depending on implementation. Healthcare and service-based businesses worldwide report baseline no-show rates of 15-30% in outpatient clinics, representing substantial lost revenue and inefficient resource utilization. SMS reminders provide a cost-effective solution that requires minimal staff intervention while improving patient attendance and allowing better schedule management through increased cancellation and rescheduling rates.
Technology Stack:
RedwoodJS: Full-stack JavaScript framework combining React, GraphQL, and Prisma – ideal for rapid full-stack development with built-in conventions (current stable version: 8.x as of 2025)
Node.js: v20.x or later – runtime environment powering RedwoodJS's API layer
MessageBird SMS API: Cloud communication platform for SMS messaging, voice, and phone number validation
Prisma ORM: Type-safe database client for PostgreSQL/SQLite with excellent TypeScript support
PostgreSQL/SQLite: Relational database for appointment data (PostgreSQL recommended for production, SQLite for development)
React: Frontend library for building the booking interface
GraphQL: API layer for type-safe communication between frontend and backend
Moment.js: Date/time manipulation library (consider date-fns or Luxon for new projects due to better bundle size and maintenance)
System Architecture Overview:
graph LR
A[User's Browser] -- HTTP/GraphQL Request --> B(RedwoodJS Web Server);
B -- GraphQL Mutation --> C(RedwoodJS API Server);
C -- Validate Input --> C;
C -- Lookup Request --> D(MessageBird Lookup API);
D -- Validation Result --> C;
C -- Schedule SMS Request --> E(MessageBird Messages API);
E -- Confirmation --> C;
C -- Save Appointment --> F(Database via Prisma);
F -- Saved Data --> C;
C -- GraphQL Response --> B;
B -- HTTP Response --> A;
E -- Sends SMS at Scheduled Time --> G(User's Phone);
classDef default fill:#f9f,stroke:#333,stroke-width:2px;
classDef redwood fill:#bfd,stroke:#333,stroke-width:2px;
classDef messagebird fill:#dff,stroke:#333,stroke-width:2px;
classDef db fill:#fdb,stroke:#333,stroke-width:2px;
class A,G default;
class B,C redwood;
class D,E messagebird;
class F db;
Prerequisites for This Tutorial:
Node.js v20.x or later installed
Yarn v1.22.21 or later package manager
MessageBird account with API access (sign up at MessageBird.com)
PostgreSQL database access (or use SQLite for local development)
Basic knowledge of React, GraphQL, and JavaScript/TypeScript
What You'll Have at the End:
A fully functional appointment booking system with automated SMS reminder capabilities. You'll understand how to integrate MessageBird for phone validation and message scheduling, use Prisma for database management, and leverage RedwoodJS conventions for rapid full-stack development. This foundation enables extensions like user authentication, cancellation workflows, and administrative dashboards.
1. Set Up Your RedwoodJS Project for SMS Scheduling
Create a new RedwoodJS application and configure dependencies for SMS messaging. Use TypeScript for enhanced type safety and better developer experience.
Create Your RedwoodJS App:
Open your terminal and run the RedwoodJS create command:
This scaffolds a new RedwoodJS project with TypeScript configured in a directory named redwood-messagebird-reminders.
Navigate to Your Project Directory:
Bash
cd redwood-messagebird-reminders
Install Your Initial Dependencies:
RedwoodJS automatically runs yarn install after creation. To run it manually:
Bash
yarn install
Configure Your Environment Variables:
RedwoodJS uses a .env file for environment variables. Create this file in your project root:
Bash
touch .env
Open .env and add these variables. Get the values in subsequent steps.
.env
# .env
# Database Connection String (Replace with your actual connection string)
# Example for PostgreSQL: DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
# Example for SQLite (for development): DATABASE_URL="file:./dev.db"
DATABASE_URL="file:./dev.db" # Start with SQLite for simplicity
# MessageBird API Key (Get from MessageBird Dashboard → Developers → API access)
MESSAGEBIRD_API_KEY="YOUR_MESSAGEBIRD_LIVE_API_KEY"
# Default Country Code for Phone Number Lookup (e.g., US, NL, GB)
MESSAGEBIRD_COUNTRY_CODE="US"
# MessageBird Originator (Sender ID - alphanumeric or phone number)
# Check Country Restrictions: https://developers.messagebird.com/api/sms-messaging/#country-restrictions
# Use a purchased MessageBird number if alphanumeric is restricted (e.g., in the US)
MESSAGEBIRD_ORIGINATOR="BeautyBird" # Or your MessageBird Number e.g. +12025550181
Why Use .env? Store sensitive information like API keys and database URLs separate from code – this prevents accidental exposure. Environment variables provide a standard, secure way to manage configuration across different environments (development, staging, production). Redwood automatically loads variables from .env into process.env.
Install Your Additional API Dependencies:
Install the MessageBird Node.js SDK and Moment.js for date manipulation within your API service. Navigate to the api workspace:
Bash
yarn workspace api add messagebird moment
# Note: Moment.js is in maintenance mode. Consider alternatives like date-fns or Luxon for new projects.
Why Use yarn workspace api add? Redwood uses Yarn Workspaces to manage dependencies for the web and api sides separately. This command ensures these packages are added only to the api side where you need them.
Make Your Initial Git Commit (Recommended):
Initialize a Git repository and make your first commit:
You now have a basic RedwoodJS project structure with the necessary configurations and dependencies ready for development.
2. Build Your Core Functionality (API & Database)
Design your database schema, implement booking logic with phone validation, and set up GraphQL endpoints for appointment management.
Define Your Database Schema (Prisma):
Open the Prisma schema file at api/db/schema.prisma. Replace the default example model with your Appointment model:
Prisma
// api/db/schema.prisma
datasource db {
provider = "sqlite" // Or "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native" // Add other targets like "rhel-openssl-1.0.x" if needed for deployment
}
model Appointment {
id Int @id @default(autoincrement())
name String
treatment String
phoneNumber String // Store the validated, normalized number in E.164 format
appointmentDt DateTime // The actual date/time of the appointment (store as UTC)
reminderDt DateTime // The date/time the reminder should be sent (store as UTC)
messageBirdId String? // Optional: Store the MessageBird message ID for tracking
createdAt DateTime @default(now())
}
Why This Schema? This captures essential appointment details, including separate fields for the appointment time (appointmentDt) and the scheduled reminder time (reminderDt). Storing the messageBirdId lets you track the scheduled message status later if needed. Phone numbers are stored in E.164 format (international standard with country code, up to 15 digits). Store dates in UTC to avoid timezone issues.
Apply Your Database Migrations:
Use Prisma Migrate to create the Appointment table in your database based on the schema changes.
Bash
yarn rw prisma migrate dev
Enter a name for the migration when prompted. Something like create appointment model works well
Why Use migrate dev? This command compares your schema.prisma with the database state, generates SQL migration files, applies them to your development database, and regenerates the Prisma Client. This keeps your database schema in sync with your application model
Generate Your GraphQL SDL and Service:
Redwood's generators can scaffold the basic GraphQL schema definition language (SDL) files and service implementations for CRUD operations.
Bash
yarn rw generate sdl Appointment --crud
This command creates/updates:
api/src/graphql/appointments.sdl.ts: Defines the GraphQL types (Appointment, CreateAppointmentInput, UpdateAppointmentInput) and operations (queries/mutations)
api/src/services/appointments/appointments.ts: Contains the business logic (resolvers) for interacting with the Appointment model
api/src/services/appointments/appointments.scenarios.ts: For defining seed data for tests
api/src/services/appointments/appointments.test.ts: Basic test file structure
Customize Your GraphQL SDL:
Open api/src/graphql/appointments.sdl.ts. Adjust the CreateAppointmentInput to match the fields you'll collect from the user form (excluding fields generated by the backend like reminderDt, messageBirdId, createdAt).
TypeScript
// api/src/graphql/appointments.sdl.tsexportconst schema = gql`type Appointment {id: Int!name: String!treatment: String!phoneNumber: String!appointmentDt: DateTime!reminderDt: DateTime!messageBirdId: String
createdAt: DateTime!}typeQuery{appointments:[Appointment!]!@requireAuth
appointment(id: Int!): Appointment @requireAuth}input CreateAppointmentInput {name: String!treatment: String!number: String!# Use 'number' from form input initiallydate: String!# Receive date as string from form (YYYY-MM-DD)time: String!# Receive time as string from form (HH:mm)# Consider accepting a single ISO 8601 DateTime string for robustness}input UpdateAppointmentInput {name: String
treatment: String
number: String
date: String
time: String
# We generally wouldn't update reminder details this way}typeMutation{
createAppointment(input: CreateAppointmentInput!): Appointment!@skipAuth# Allow public booking
updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment!@requireAuth
deleteAppointment(id: Int!): Appointment!@requireAuth}
`
Key Changes:
Modified CreateAppointmentInput to accept number, date, and time as strings – reflects typical HTML form input. Added comment suggesting ISO 8601
Added @skipAuth to createAppointment mutation to allow unauthenticated users to book. Keep @requireAuth for other operations
Implement Your Service Logic:
This is where your core booking and scheduling logic lives. Open api/src/services/appointments/appointments.ts and modify the createAppointment function significantly.
TypeScript
// api/src/services/appointments/appointments.tsimporttype { MutationResolvers, QueryResolvers } from'types/graphql'import { validate } from'@redwoodjs/api'// For basic input presence validationimport { RedwoodUser, db } from'src/lib/db'// Prisma clientimport { logger } from'src/lib/logger'// Redwood LoggerimportMessageBirdfrom'messagebird'// MessageBird SDKimport moment from'moment'// Moment.js for date handling (Consider date-fns/Luxon)// Initialize MessageBird Client (only once)letmessagebird: MessageBird.MessageBird | null = null;
try {
// Validate that the API key is presentif (!process.env.MESSAGEBIRD_API_KEY) {
thrownewError('MESSAGEBIRD_API_KEY environment variable not set.')
}
messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY)
logger.info('MessageBird SDK initialized successfully.')
} catch (error) {
logger.error({ error }, 'Failed to initialize MessageBird SDK:')
// Depending on requirements, you might want the app to fail startup.// Currently, the app continues running, but createAppointment will fail later if the SDK is needed.
}
// Helper function for date validationconstvalidateAppointmentDateTime = (dateStr: string, timeStr: string) => {
// WARNING: This parsing relies on the server's local time zone.// For robust handling, parse using UTC or ensure the input includes timezone info (e.g., ISO 8601).// Consider using moment.utc() or switching to a library that handles timezones explicitly.const appointmentDateTime = moment(`${dateStr}${timeStr}`, 'YYYY-MM-DD HH:mm');
// Rule: Appointment must be at least 3 hours and 5 minutes in the futureconst earliestPossibleDateTime = moment().add({ hours: 3, minutes: 5 });
if (!appointmentDateTime.isValid()) {
thrownewError('Invalid date or time format provided.');
}
if (appointmentDateTime.isBefore(earliestPossibleDateTime)) {
thrownewError('Appointment must be scheduled at least 3 hours and 5 minutes from now.');
}
return appointmentDateTime; // Return the moment object (potentially in server's local time)
}
exportconstappointments: QueryResolvers['appointments'] = () => {
return db.appointment.findMany()
}
exportconstappointment: QueryResolvers['appointment'] = ({ id }) => {
return db.appointment.findUnique({
where: { id },
})
}
exportconstcreateAppointment: MutationResolvers['createAppointment'] = async ({ input }) => {
// 0. Check if MessageBird SDK is initializedif (!messagebird) {
logger.error('MessageBird SDK not available. Cannot process appointment.')
thrownewError('Appointment scheduling service is temporarily unavailable.')
}
// 1. Basic Input Validation (Presence)validate(input.name, 'Name', { presence: true })
validate(input.treatment, 'Treatment', { presence: true })
validate(input.number, 'Phone Number', { presence: true })
validate(input.date, 'Date', { presence: true })
validate(input.time, 'Time', { presence: true })
letvalidatedPhoneNumber: string;
letnormalizedPhoneNumber: string;
try {
// 2. Validate Appointment Date/Time// Note: appointmentDateTime may be in server's local time based on validateAppointmentDateTime implementation.const appointmentDateTime = validateAppointmentDateTime(input.date, input.time);
// Calculate reminder time relative to the (potentially local) appointment time.const reminderDateTime = appointmentDateTime.clone().subtract({ hours: 3 });
// 3. Validate Phone Number via MessageBird Lookup API// The Lookup API returns the phone number in E.164 format and validates it's a valid mobile number// Reference: https://developers.messagebird.com/api/lookupawaitnewPromise<void>((resolve, reject) => {
const countryCode = process.env.MESSAGEBIRD_COUNTRY_CODE || undefined; // Use undefined if not set
messagebird!.lookup.read(input.number, countryCode, (err, response) => {
if (err) {
// Specific error for invalid format (error code 21)if (err.errors && err.errors[0].code === 21) {
logger.warn({ number: input.number, error: err }, 'Invalid phone number format provided.');
returnreject(newError('Enter a valid phone number format.'));
}
// Other lookup errors
logger.error({ number: input.number, error: err }, 'MessageBird Lookup API error.');
returnreject(newError('Could not validate phone number. Try again.'));
}
// Check if the number type is mobileif (response && response.type !== 'mobile') {
logger.warn({ number: input.number, type: response.type }, 'Non-mobile phone number provided.');
returnreject(newError('Provide a mobile phone number to receive SMS reminders.'));
}
// Success - store the validated, normalized number in E.164 format
validatedPhoneNumber = input.number; // Or keep original if preferred
normalizedPhoneNumber = response.phoneNumber; // E.164 format (e.g., 31612345678)
logger.info({ number: input.number, normalized: normalizedPhoneNumber }, 'Phone number validated successfully.');
resolve();
});
});
// 4. Schedule Reminder SMS via MessageBird Messages API// Reference: https://developers.messagebird.com/api/sms-messaging// scheduledDatetime must be in RFC3339 format (ISO 8601), preferably UTCconstmessageParams: MessageBird.MessageParameters = {
originator: process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird', // Fallback originatorrecipients: [normalizedPhoneNumber],
// Use ISO 8601 format (UTC is strongly recommended). Moment's toISOString() provides this.scheduledDatetime: reminderDateTime.toISOString(),
body: `${input.name}, here's a reminder for your ${input.treatment} appointment scheduled for ${appointmentDateTime.format('HH:mm')} today. See you soon!`,
// Optional: Reference, Report URL etc.// reference: `appointment_${SOME_UNIQUE_ID}`
};
const messageResponse = awaitnewPromise<MessageBird.Message>((resolve, reject) => {
messagebird!.messages.create(messageParams, (err, response) => {
if (err) {
logger.error({ error: err, params: messageParams }, 'Failed to schedule MessageBird SMS.');
returnreject(newError('Failed to schedule the SMS reminder. Try booking again.'));
}
logger.info({ response }, 'MessageBird SMS scheduled successfully.');
resolve(response);
});
});
// 5. Store Appointment in Databaseconst createdAppointment = await db.appointment.create({
data: {
name: input.name,
treatment: input.treatment,
phoneNumber: normalizedPhoneNumber, // Store normalized E.164 number// Convert moment object to standard Date object for Prisma.// Ideally, ensure these are stored as UTC in the DB.appointmentDt: appointmentDateTime.toDate(),
reminderDt: reminderDateTime.toDate(),
messageBirdId: messageResponse.id, // Store message ID for tracking
},
});
logger.info({ appointmentId: createdAppointment.id }, 'Appointment created successfully in DB.');
return createdAppointment;
} catch (error: any) {
logger.error({ error: error, input }, 'Error creating appointment:');
// Re-throw the specific error message for the frontendthrownewError(error.message || 'An unexpected error occurred while booking the appointment.');
}
}
// --- Keep other generated resolvers (updateAppointment, deleteAppointment) ---exportconstupdateAppointment: MutationResolvers['updateAppointment'] = ({
id,
input,
}) => {
// Add validation and logic for updates if needed.// IMPORTANT: This stub does NOT handle updating/canceling the previously scheduled// MessageBird reminder. A full implementation would need to fetch the existing appointment,// potentially cancel the old message via MessageBird API (using messageBirdId),// and schedule a new one if the time changed. This is out of scope for this guide.
logger.warn(`Update operation called for appointment ${id} - MessageBird cancellation/rescheduling not implemented.`);
thrownewError('Updating appointments with reminder rescheduling is not supported in this example.');
// return db.appointment.update({// data: input, // Careful with direct input mapping// where: { id },// })
}
exportconstdeleteAppointment: MutationResolvers['deleteAppointment'] = async ({
id,
}) => {
// IMPORTANT: This stub does NOT handle canceling the scheduled MessageBird reminder.// A full implementation would need to fetch the appointment record BEFORE deleting it,// get the `messageBirdId`, and make a separate API call to MessageBird to attempt// cancellation of the scheduled message. This is out of scope for this guide.
logger.warn(`Delete operation called for appointment ${id} - MessageBird SMS cancellation not implemented.`);
// Fetch the appointment first if you needed to cancel the message// const appointmentToDelete = await db.appointment.findUnique({ where: { id } });// if (appointmentToDelete?.messageBirdId) {// // Call MessageBird API to cancel message ID appointmentToDelete.messageBirdId// }// Then delete from DBreturn db.appointment.delete({
where: { id },
})
}
Explanation:
Initialization: Added comment clarifying the implication of not failing fast on SDK init error.
Validation: Added comments to validateAppointmentDateTime warning about server-local time zone parsing and recommending UTC/ISO 8601.
Lookup: Calls messagebird.lookup.read asynchronously using Promises. Handles errors and checks if the number is mobile. Throws specific, user-friendly errors. Stores the E.164 formatted normalizedPhoneNumber.
Scheduling: Calculates the reminder time. Creates parameters for messagebird.messages.create, ensuring scheduledDatetime is formatted as ISO 8601 (UTC recommended). Uses Promises for async handling.
Database: Saves the appointment details, converting Moment objects to Dates. Added comment about ensuring UTC storage.
Error Handling: Uses try...catch blocks. Logs detailed errors using Redwood's logger and throws user-friendly error messages.
Update/Delete: Enhanced comments to explicitly state that MessageBird message cancellation/rescheduling is not implemented and is out of scope.
You've now implemented your core backend logic. Your API can accept booking requests, validate data and phone numbers, schedule SMS reminders via MessageBird, and save appointments to your database.
3. Build Your Frontend Interface
Create the React components and page for users to interact with your booking system.
Generate Your Page and Component:
Use Redwood generators to create the page and a reusable form component.
web/src/pages/BookingPage/BookingPage.tsx (and related files) accessible at the /booking route
web/src/components/BookingForm/BookingForm.tsx (and related files)
Implement Your Booking Form Component:
Open web/src/components/BookingForm/BookingForm.tsx. Use Redwood's form helpers for easier state management and validation handling.
TypeScript
// web/src/components/BookingForm/BookingForm.tsximport {
Form,
FormError,
Label,
TextField,
DatetimeLocalField, // Using DatetimeLocalField simplifies date/time inputSubmit,
FieldError,
useForm,
} from'@redwoodjs/forms'import { navigate, routes } from'@redwoodjs/router'import { useMutation } from'@redwoodjs/web'import { toast } from'@redwoodjs/web/toast'import { logger } from'src/lib/logger'// Use Redwood's web logger if needed// Consider adding a date library like date-fns for robust parsing/formatting// import { parseISO, format } from 'date-fns'// GraphQL Mutation Definition (must match the backend SDL)constCREATE_APPOINTMENT_MUTATION = gql`mutation CreateAppointmentMutation($input: CreateAppointmentInput!){
createAppointment(input:$input) {
id # Request the ID upon successful creation}}
`// Constants for minimum time calculation clarityconstMIN_HOURS_ADVANCE = 3;
constMIN_MINUTES_BUFFER = 15; // Add buffer to ensure validation passesinterfaceBookingFormProps {
onSuccess?: () =>void// Optional callback after successful submission
}
constBookingForm = ({ onSuccess }: BookingFormProps) => {
const formMethods = useForm({ mode: 'onBlur' }) // Use onBlur validation modeconst [create, { loading, error }] = useMutation(
CREATE_APPOINTMENT_MUTATION,
{
onCompleted: (data) => {
toast.success('Appointment booked successfully!')
logger.info({ appointmentId: data.createAppointment.id }, 'Appointment created via form.')
// Navigate to a confirmation page or call onSuccess callbacknavigate(routes.bookingSuccess({ id: data.createAppointment.id })) // Example: Navigate to a success page// onSuccess?.() // Alternative: Call callback if provided
formMethods.reset() // Reset the form fields
},
onError: (error) => {
logger.error({ error }, 'Error booking appointment via form:')
toast.error(`Booking failed: ${error.message}`)
},
}
)
constonSubmit = (data) => {
// Redwood's DatetimeLocalField provides ISO string like "YYYY-MM-DDTHH:mm"// We need to split it for our backend mutation input which expects separate date and time strings.// WARNING: Native `new Date()` parsing can be unreliable across browsers/locales for formats// other than strict ISO 8601 with timezone (like the one from DatetimeLocalField).// Using a library like date-fns (e.g., parseISO) is more robust.try {
const dateTime = newDate(data.appointmentDateTime);
if (isNaN(dateTime.getTime())) {
thrownewError('Invalid date/time selected.')
}
// NOTE: The following relies on specific string formats and might be fragile.// Using date library functions (e.g., date-fns format(dateTime, 'yyyy-MM-dd')) is preferred.const dateString = dateTime.toISOString().split('T')[0]; // Extracts YYYY-MM-DD from UTC ISO stringconst timeString = dateTime.toTimeString().split(' ')[0].substring(0, 5); // Extracts HH:mm from local time stringconst input = {
name: data.name,
treatment: data.treatment,
number: data.number,
date: dateString,
time: timeString,
}
logger.debug({ input } , 'Submitting booking form data')
create({ variables: { input } })
} catch (e) {
logger.error({ error: e, formData: data}, 'Error processing form data before submission')
toast.error(`Error processing input: ${e.message}`)
}
}
// Helper to set min date/time for the input fieldconstgetMinDateTime = () => {
const now = newDate();
// Add minimum advance time (3 hours from backend rule) plus a buffer (15 mins)// to ensure the selected time is valid when it reaches the server.
now.setHours(now.getHours() + MIN_HOURS_ADVANCE);
now.setMinutes(now.getMinutes() + MIN_MINUTES_BUFFER);
// Format for datetime-local input requires 'YYYY-MM-DDTHH:mm' in local time.// Need to adjust for timezone offset if using Date methods that operate in UTC.const offset = now.getTimezoneOffset() // Offset in minutes from UTCconst adjustedDate = newDate(now.getTime() - (offset*60*1000)) // Adjust to local timereturn adjustedDate.toISOString().slice(0,16) // Format to YYYY-MM-DDTHH:mm
}
return (
<divclassName="rw-form-wrapper"><FormonSubmit={onSubmit}error={error}formMethods={formMethods}><FormErrorerror={error}wrapperClassName="rw-form-error-wrapper" /><Labelname="name"className="rw-label"errorClassName="rw-label rw-label-error">
Your Name
</Label><TextFieldname="name"className="rw-input"errorClassName="rw-input rw-input-error"validation={{required:true }}
/><FieldErrorname="name"className="rw-field-error" /><Labelname="treatment"className="rw-label"errorClassName="rw-label rw-label-error">
Desired Treatment
</Label><TextFieldname="treatment"className="rw-input"errorClassName="rw-input rw-input-error"validation={{required:true }}
/><FieldErrorname="treatment"className="rw-field-error" /><Labelname="number"className="rw-label"errorClassName="rw-label rw-label-error">
Mobile Phone Number (for SMS reminder)
</Label><TextFieldname="number"placeholder="e.g., +14155552671 or 4155552671"className="rw-input"errorClassName="rw-input rw-input-error"validation={{required:true,
// Basicpattern-backendvalidationviaMessageBirdismorereliable
// pattern: {
// value: /^\+?[1-9]\d{1,14}$/, // SimpleE.164-likepattern
// message: 'Pleaseenteravalidphonenumberformat.',
// },
}}
/><FieldErrorname="number"className="rw-field-error" /><Labelname="appointmentDateTime"className="rw-label"errorClassName="rw-label rw-label-error">
Appointment Date and Time
</Label>
{/* Use datetime-local for combined input */}
<DatetimeLocalFieldname="appointmentDateTime"className="rw-input"errorClassName="rw-input rw-input-error"validation={{required:true,
valueAsDate:true, // EnsurethevaluecanbeparsedasaDate
// Customvalidationcouldalsobeaddedhereifneeded
}}
min={getMinDateTime()} // Setminimumselectabledate/timebasedoncurrenttime + buffer
/><FieldErrorname="appointmentDateTime"className="rw-field-error" /><divclassName="rw-button-group"><Submitdisabled={loading}className="rw-button rw-button-blue">
{loading ? 'Booking…' : 'Book Appointment'}
</Submit></div></Form></div>
)
}
exportdefaultBookingForm
Explanation:
Imports: Includes necessary components from @redwoodjs/forms, @redwoodjs/router, @redwoodjs/web, and @redwoodjs/web/toast. Added logger import. Commented out date-fns import but noted its recommendation.
Mutation: Defines the CREATE_APPOINTMENT_MUTATION matching the backend SDL.
useMutation Hook: Sets up the mutation call, including onCompleted (for success toast, logging, navigation, form reset) and onError (for error toast and logging) handlers.
useForm Hook: Initializes Redwood's form handling, setting validation mode to onBlur.
onSubmit Handler:
Retrieves data from the form state.
Parses the datetime-local input string (data.appointmentDateTime) into a Date object. Includes a warning about potential native Date parsing issues and recommends a library.
Splits the Date object into YYYY-MM-DD and HH:mm strings as required by the backend mutation input. Includes a note about the fragility of string manipulation and preference for date library functions.
Constructs the input object for the mutation.
Calls the create function from useMutation.
Includes a try...catch block for errors during date/time processing before the mutation is sent.
getMinDateTime Helper: Calculates the earliest selectable date/time for the DatetimeLocalField based on the backend rule (3 hours) plus a buffer (15 minutes) to prevent validation failures due to timing differences. Formats the date correctly for the min attribute of datetime-local.
Form Structure: Uses Redwood's <Form>, <FormError>, <Label>, <TextField>, <DatetimeLocalField>, <FieldError>, and <Submit> components.
Input Fields: Sets up fields for name, treatment, phone number, and appointment date/time.
Uses DatetimeLocalField for combined date and time input, simplifying the UI.
Includes basic required validation. Commented out a basic phone number pattern, emphasizing backend validation is primary.
Sets the min attribute on DatetimeLocalField using getMinDateTime.
Submission: The <Submit> button is disabled during loading.
Implement Your Booking Page:
Open web/src/pages/BookingPage/BookingPage.tsx. This page simply renders the BookingForm component.
TypeScript
// web/src/pages/BookingPage/BookingPage.tsximport { MetaTags } from'@redwoodjs/web'importBookingFormfrom'src/components/BookingForm/BookingForm'constBookingPage = () => {
return (
<><MetaTagstitle="Book Appointment"description="Schedule your appointment" /><h1>Book Your Appointment</h1><p>Fill out the form below to book your appointment. You'll receive an SMS reminder 3 hours before your scheduled time.</p><BookingForm /></>
)
}
exportdefaultBookingPage
What This Does: Imports MetaTags for SEO and the BookingForm. Renders a heading, descriptive text, and the form component.
Add Your Routes:
Ensure your routes are defined in web/src/Routes.tsx. The generate page command should have added the /booking route. You might also want a success page route.
TypeScript
// web/src/Routes.tsximport { Router, Route, Set } from'@redwoodjs/router'importMainLayoutfrom'src/layouts/MainLayout/MainLayout'// Example layoutconstRoutes = () => {
return (
<Router>
{/* Add a simple success page component if desired */}
{/* <Routepath="/booking-success/{id:Int}"page={BookingSuccessPage}name="bookingSuccess" /> */}
<Setwrap={MainLayout}> {/* Wrap pages in a layout if you have one */}
<Routepath="/booking"page={BookingPage}name="booking" />
{/* Add other routes here */}
<Routenotfoundpage={NotFoundPage} /></Set></Router>
)
}
exportdefaultRoutes
Note: Added a commented-out example route bookingSuccess which the BookingForm attempts to navigate to. Generate and implement BookingSuccessPage for this to work fully.
Add Your Toaster:
To display success/error messages from toast, add the <Toaster /> component, typically in your main layout file (e.g., web/src/layouts/MainLayout/MainLayout.tsx).
Your frontend is now set up. Users can navigate to /booking, fill out the form, and submit it to trigger your backend logic. Feedback is provided via toasts.
4. Run and Test Your Application
Start Your Development Server:
Run the Redwood development server – starts both the API and web sides with hot-reloading.
Bash
yarn rw dev
Access Your Application:
Open your browser and navigate to http://localhost:8910/booking (or the port specified in your redwood.toml).
Test Your Booking:
Fill out the form with valid details
Use a real mobile number you have access to if you want to receive the test SMS (ensure your MessageBird account has credit)
Select a date and time at least 3 hours and 15 minutes in the future
Submit the form
Check for the success toast message
Verify the appointment record is created in your database (e.g., using Prisma Studio: yarn rw prisma studio)
Check your MessageBird dashboard (Logs → Messages) to see the scheduled SMS
Wait for the scheduled time (minus 3 hours) to see if you receive the SMS reminder
Test Your Validation:
Try submitting with empty fields
Try entering an invalid phone number format
Try entering a non-mobile number (like a landline, if MessageBird Lookup identifies it as such)
Try selecting a time less than 3 hours in the future
Observe the error messages displayed via toasts and field errors
Next Steps: Enhance Your Appointment System
You've successfully built a RedwoodJS appointment booking system with automated SMS reminders powered by MessageBird. This implementation demonstrates full-stack development with phone validation, scheduled messaging, and database persistence.
Production Enhancements:
Confirmation page – Create BookingSuccessPage component with appointment summary and next steps
Update/cancel appointments – Implement appointment management with MessageBird message cancellation using stored messageBirdId
User authentication – Add account system (yarn rw generate auth ...) for personalized appointment history
Admin dashboard – Build administrative interface for viewing, managing, and analyzing appointments
Timezone handling – Implement robust timezone support using date-fns-tz or Luxon for accurate scheduling across regions. Store all timestamps in UTC
Enhanced error handling – Provide detailed error messages and recovery paths for common issues
Production deployment – Deploy to Vercel, Netlify, or Render with proper environment variable configuration for DATABASE_URL, MESSAGEBIRD_API_KEY, and other secrets
MessageBird webhooks – Implement delivery status webhooks to track SMS delivery confirmation and failed messages
Create a new RedwoodJS project, install required dependencies like MessageBird and Prisma, define the database schema for appointments, and implement the API logic to handle bookings and scheduling. Then, build a frontend form for users to book appointments and connect it to the backend API. Finally, configure the SMS integration with MessageBird to send automated reminders.
RedwoodJS is the core framework for building the web application. It provides a full-stack, serverless-first environment based on React, GraphQL, and Prisma, enabling rapid development of both frontend and backend components.
MessageBird is a communications platform offering a reliable SMS API and phone number lookup capabilities. This integration allows for automated SMS reminders to be sent to users, minimizing no-shows and improving appointment management.
While this tutorial uses Moment.js for date/time manipulation, it's in maintenance mode. For new projects or significant updates, consider modern alternatives like date-fns, Luxon, or native JavaScript Date/Intl methods for better performance and maintainability. This is especially crucial for production-level applications
Yes, RedwoodJS supports authentication features. You can use Redwood's built-in authentication generators to easily add user accounts, enabling users to manage their own appointments and access personalized features. After generating authentication you can add the @requireAuth directive to the relevant services.
The guide provides examples for PostgreSQL and SQLite. PostgreSQL is generally recommended for production environments, while SQLite is suitable for simpler development and testing purposes. The Prisma ORM handles database interactions.
The application uses the MessageBird Lookup API to validate phone numbers. This API verifies the number's format and identifies the number type (e.g., mobile, landline), ensuring only valid mobile numbers are used for SMS reminders.
The application enforces a minimum booking time of 3 hours and 5 minutes in the future. This time constraint is implemented in the backend service logic, ensuring adequate time for reminder scheduling.
The tutorial implementation uses date-time parsing that depends on the server's time zone, which can be problematic. Ideally, you should implement robust timezone handling using specialized libraries like date-fns-tz or Luxon, storing all dates and times in UTC format.
While not covered in the initial setup, implementing MessageBird webhooks allows receiving real-time status updates for SMS messages. This provides feedback on delivery success or failure and enables more advanced features like retry mechanisms or notifications.
Appointment data is stored in a relational database (PostgreSQL or SQLite). The application uses Prisma, a next-generation ORM, to define the database schema, manage migrations, and interact with the database seamlessly from the API side.
RedwoodJS utilizes GraphQL for communication between the frontend and the backend API. The frontend uses GraphQL queries and mutations to request and modify data, while the backend defines the GraphQL schema and resolvers that handle these requests.
The optional messageBirdId field stores the unique identifier assigned to the scheduled SMS message by MessageBird. This allows for tracking the message status and potentially canceling or rescheduling it later.
RedwoodJS offers deployment options for various platforms like Vercel, Netlify, and Render. Consult the RedwoodJS deployment documentation for specific instructions. Make sure to properly configure your environment variables (DATABASE_URL, MESSAGEBIRD_API_KEY, etc.) in your production environment.
Build a production-ready appointment booking system with automated SMS reminders using RedwoodJS and MessageBird. This comprehensive tutorial covers everything from project setup and database design to phone validation and deployment – perfect for developers creating appointment-based applications with SMS notifications.
Modern appointment booking systems require reliable SMS notifications to reduce no-shows and improve customer experience. This guide shows you how to leverage RedwoodJS's full-stack capabilities with MessageBird's SMS API to create an automated reminder system that validates phone numbers, schedules messages, and manages appointments efficiently.
How to Build an SMS Appointment Reminder System with RedwoodJS and MessageBird
Create a complete appointment booking application with automated SMS notifications using these production-ready features:
Note: This tutorial focuses on core booking and reminder functionality. Appointment updates and cancellations (including MessageBird message management) require additional implementation for production systems.
Why Build This Appointment Reminder System:
Automated SMS reminders reduce appointment no-shows significantly, with studies showing reductions between 5-38% depending on implementation. Healthcare and service-based businesses worldwide report baseline no-show rates of 15-30% in outpatient clinics, representing substantial lost revenue and inefficient resource utilization. SMS reminders provide a cost-effective solution that requires minimal staff intervention while improving patient attendance and allowing better schedule management through increased cancellation and rescheduling rates.
Technology Stack:
date-fnsor Luxon for new projects due to better bundle size and maintenance)System Architecture Overview:
Prerequisites for This Tutorial:
What You'll Have at the End:
A fully functional appointment booking system with automated SMS reminder capabilities. You'll understand how to integrate MessageBird for phone validation and message scheduling, use Prisma for database management, and leverage RedwoodJS conventions for rapid full-stack development. This foundation enables extensions like user authentication, cancellation workflows, and administrative dashboards.
1. Set Up Your RedwoodJS Project for SMS Scheduling
Create a new RedwoodJS application and configure dependencies for SMS messaging. Use TypeScript for enhanced type safety and better developer experience.
Create Your RedwoodJS App: Open your terminal and run the RedwoodJS create command:
This scaffolds a new RedwoodJS project with TypeScript configured in a directory named
redwood-messagebird-reminders.Navigate to Your Project Directory:
cd redwood-messagebird-remindersInstall Your Initial Dependencies: RedwoodJS automatically runs
yarn installafter creation. To run it manually:Configure Your Environment Variables: RedwoodJS uses a
.envfile for environment variables. Create this file in your project root:touch .envOpen
.envand add these variables. Get the values in subsequent steps.Why Use
.env? Store sensitive information like API keys and database URLs separate from code – this prevents accidental exposure. Environment variables provide a standard, secure way to manage configuration across different environments (development, staging, production). Redwood automatically loads variables from.envintoprocess.env.Install Your Additional API Dependencies: Install the MessageBird Node.js SDK and Moment.js for date manipulation within your API service. Navigate to the
apiworkspace:yarn workspace api add messagebird moment # Note: Moment.js is in maintenance mode. Consider alternatives like date-fns or Luxon for new projects.Why Use
yarn workspace api add? Redwood uses Yarn Workspaces to manage dependencies for thewebandapisides separately. This command ensures these packages are added only to theapiside where you need them.Make Your Initial Git Commit (Recommended): Initialize a Git repository and make your first commit:
git init git add . git commit -m "Initial project setup with RedwoodJS"You now have a basic RedwoodJS project structure with the necessary configurations and dependencies ready for development.
2. Build Your Core Functionality (API & Database)
Design your database schema, implement booking logic with phone validation, and set up GraphQL endpoints for appointment management.
Define Your Database Schema (Prisma): Open the Prisma schema file at
api/db/schema.prisma. Replace the default example model with yourAppointmentmodel:Why This Schema? This captures essential appointment details, including separate fields for the appointment time (
appointmentDt) and the scheduled reminder time (reminderDt). Storing themessageBirdIdlets you track the scheduled message status later if needed. Phone numbers are stored in E.164 format (international standard with country code, up to 15 digits). Store dates in UTC to avoid timezone issues.Apply Your Database Migrations: Use Prisma Migrate to create the
Appointmenttable in your database based on the schema changes.create appointment modelworks wellmigrate dev? This command compares yourschema.prismawith the database state, generates SQL migration files, applies them to your development database, and regenerates the Prisma Client. This keeps your database schema in sync with your application modelGenerate Your GraphQL SDL and Service: Redwood's generators can scaffold the basic GraphQL schema definition language (SDL) files and service implementations for CRUD operations.
This command creates/updates:
api/src/graphql/appointments.sdl.ts: Defines the GraphQL types (Appointment,CreateAppointmentInput,UpdateAppointmentInput) and operations (queries/mutations)api/src/services/appointments/appointments.ts: Contains the business logic (resolvers) for interacting with theAppointmentmodelapi/src/services/appointments/appointments.scenarios.ts: For defining seed data for testsapi/src/services/appointments/appointments.test.ts: Basic test file structureCustomize Your GraphQL SDL: Open
api/src/graphql/appointments.sdl.ts. Adjust theCreateAppointmentInputto match the fields you'll collect from the user form (excluding fields generated by the backend likereminderDt,messageBirdId,createdAt).// api/src/graphql/appointments.sdl.ts export const schema = gql` type Appointment { id: Int! name: String! treatment: String! phoneNumber: String! appointmentDt: DateTime! reminderDt: DateTime! messageBirdId: String createdAt: DateTime! } type Query { appointments: [Appointment!]! @requireAuth appointment(id: Int!): Appointment @requireAuth } input CreateAppointmentInput { name: String! treatment: String! number: String! # Use 'number' from form input initially date: String! # Receive date as string from form (YYYY-MM-DD) time: String! # Receive time as string from form (HH:mm) # Consider accepting a single ISO 8601 DateTime string for robustness } input UpdateAppointmentInput { name: String treatment: String number: String date: String time: String # We generally wouldn't update reminder details this way } type Mutation { createAppointment(input: CreateAppointmentInput!): Appointment! @skipAuth # Allow public booking updateAppointment(id: Int!, input: UpdateAppointmentInput!): Appointment! @requireAuth deleteAppointment(id: Int!): Appointment! @requireAuth } `Key Changes:
CreateAppointmentInputto acceptnumber,date, andtimeas strings – reflects typical HTML form input. Added comment suggesting ISO 8601@skipAuthtocreateAppointmentmutation to allow unauthenticated users to book. Keep@requireAuthfor other operationsImplement Your Service Logic: This is where your core booking and scheduling logic lives. Open
api/src/services/appointments/appointments.tsand modify thecreateAppointmentfunction significantly.// api/src/services/appointments/appointments.ts import type { MutationResolvers, QueryResolvers } from 'types/graphql' import { validate } from '@redwoodjs/api' // For basic input presence validation import { RedwoodUser, db } from 'src/lib/db' // Prisma client import { logger } from 'src/lib/logger' // Redwood Logger import MessageBird from 'messagebird' // MessageBird SDK import moment from 'moment' // Moment.js for date handling (Consider date-fns/Luxon) // Initialize MessageBird Client (only once) let messagebird: MessageBird.MessageBird | null = null; try { // Validate that the API key is present if (!process.env.MESSAGEBIRD_API_KEY) { throw new Error('MESSAGEBIRD_API_KEY environment variable not set.') } messagebird = MessageBird(process.env.MESSAGEBIRD_API_KEY) logger.info('MessageBird SDK initialized successfully.') } catch (error) { logger.error({ error }, 'Failed to initialize MessageBird SDK:') // Depending on requirements, you might want the app to fail startup. // Currently, the app continues running, but createAppointment will fail later if the SDK is needed. } // Helper function for date validation const validateAppointmentDateTime = (dateStr: string, timeStr: string) => { // WARNING: This parsing relies on the server's local time zone. // For robust handling, parse using UTC or ensure the input includes timezone info (e.g., ISO 8601). // Consider using moment.utc() or switching to a library that handles timezones explicitly. const appointmentDateTime = moment(`${dateStr} ${timeStr}`, 'YYYY-MM-DD HH:mm'); // Rule: Appointment must be at least 3 hours and 5 minutes in the future const earliestPossibleDateTime = moment().add({ hours: 3, minutes: 5 }); if (!appointmentDateTime.isValid()) { throw new Error('Invalid date or time format provided.'); } if (appointmentDateTime.isBefore(earliestPossibleDateTime)) { throw new Error('Appointment must be scheduled at least 3 hours and 5 minutes from now.'); } return appointmentDateTime; // Return the moment object (potentially in server's local time) } export const appointments: QueryResolvers['appointments'] = () => { return db.appointment.findMany() } export const appointment: QueryResolvers['appointment'] = ({ id }) => { return db.appointment.findUnique({ where: { id }, }) } export const createAppointment: MutationResolvers['createAppointment'] = async ({ input }) => { // 0. Check if MessageBird SDK is initialized if (!messagebird) { logger.error('MessageBird SDK not available. Cannot process appointment.') throw new Error('Appointment scheduling service is temporarily unavailable.') } // 1. Basic Input Validation (Presence) validate(input.name, 'Name', { presence: true }) validate(input.treatment, 'Treatment', { presence: true }) validate(input.number, 'Phone Number', { presence: true }) validate(input.date, 'Date', { presence: true }) validate(input.time, 'Time', { presence: true }) let validatedPhoneNumber: string; let normalizedPhoneNumber: string; try { // 2. Validate Appointment Date/Time // Note: appointmentDateTime may be in server's local time based on validateAppointmentDateTime implementation. const appointmentDateTime = validateAppointmentDateTime(input.date, input.time); // Calculate reminder time relative to the (potentially local) appointment time. const reminderDateTime = appointmentDateTime.clone().subtract({ hours: 3 }); // 3. Validate Phone Number via MessageBird Lookup API // The Lookup API returns the phone number in E.164 format and validates it's a valid mobile number // Reference: https://developers.messagebird.com/api/lookup await new Promise<void>((resolve, reject) => { const countryCode = process.env.MESSAGEBIRD_COUNTRY_CODE || undefined; // Use undefined if not set messagebird!.lookup.read(input.number, countryCode, (err, response) => { if (err) { // Specific error for invalid format (error code 21) if (err.errors && err.errors[0].code === 21) { logger.warn({ number: input.number, error: err }, 'Invalid phone number format provided.'); return reject(new Error('Enter a valid phone number format.')); } // Other lookup errors logger.error({ number: input.number, error: err }, 'MessageBird Lookup API error.'); return reject(new Error('Could not validate phone number. Try again.')); } // Check if the number type is mobile if (response && response.type !== 'mobile') { logger.warn({ number: input.number, type: response.type }, 'Non-mobile phone number provided.'); return reject(new Error('Provide a mobile phone number to receive SMS reminders.')); } // Success - store the validated, normalized number in E.164 format validatedPhoneNumber = input.number; // Or keep original if preferred normalizedPhoneNumber = response.phoneNumber; // E.164 format (e.g., 31612345678) logger.info({ number: input.number, normalized: normalizedPhoneNumber }, 'Phone number validated successfully.'); resolve(); }); }); // 4. Schedule Reminder SMS via MessageBird Messages API // Reference: https://developers.messagebird.com/api/sms-messaging // scheduledDatetime must be in RFC3339 format (ISO 8601), preferably UTC const messageParams: MessageBird.MessageParameters = { originator: process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird', // Fallback originator recipients: [normalizedPhoneNumber], // Use ISO 8601 format (UTC is strongly recommended). Moment's toISOString() provides this. scheduledDatetime: reminderDateTime.toISOString(), body: `${input.name}, here's a reminder for your ${input.treatment} appointment scheduled for ${appointmentDateTime.format('HH:mm')} today. See you soon!`, // Optional: Reference, Report URL etc. // reference: `appointment_${SOME_UNIQUE_ID}` }; const messageResponse = await new Promise<MessageBird.Message>((resolve, reject) => { messagebird!.messages.create(messageParams, (err, response) => { if (err) { logger.error({ error: err, params: messageParams }, 'Failed to schedule MessageBird SMS.'); return reject(new Error('Failed to schedule the SMS reminder. Try booking again.')); } logger.info({ response }, 'MessageBird SMS scheduled successfully.'); resolve(response); }); }); // 5. Store Appointment in Database const createdAppointment = await db.appointment.create({ data: { name: input.name, treatment: input.treatment, phoneNumber: normalizedPhoneNumber, // Store normalized E.164 number // Convert moment object to standard Date object for Prisma. // Ideally, ensure these are stored as UTC in the DB. appointmentDt: appointmentDateTime.toDate(), reminderDt: reminderDateTime.toDate(), messageBirdId: messageResponse.id, // Store message ID for tracking }, }); logger.info({ appointmentId: createdAppointment.id }, 'Appointment created successfully in DB.'); return createdAppointment; } catch (error: any) { logger.error({ error: error, input }, 'Error creating appointment:'); // Re-throw the specific error message for the frontend throw new Error(error.message || 'An unexpected error occurred while booking the appointment.'); } } // --- Keep other generated resolvers (updateAppointment, deleteAppointment) --- export const updateAppointment: MutationResolvers['updateAppointment'] = ({ id, input, }) => { // Add validation and logic for updates if needed. // IMPORTANT: This stub does NOT handle updating/canceling the previously scheduled // MessageBird reminder. A full implementation would need to fetch the existing appointment, // potentially cancel the old message via MessageBird API (using messageBirdId), // and schedule a new one if the time changed. This is out of scope for this guide. logger.warn(`Update operation called for appointment ${id} - MessageBird cancellation/rescheduling not implemented.`); throw new Error('Updating appointments with reminder rescheduling is not supported in this example.'); // return db.appointment.update({ // data: input, // Careful with direct input mapping // where: { id }, // }) } export const deleteAppointment: MutationResolvers['deleteAppointment'] = async ({ id, }) => { // IMPORTANT: This stub does NOT handle canceling the scheduled MessageBird reminder. // A full implementation would need to fetch the appointment record BEFORE deleting it, // get the `messageBirdId`, and make a separate API call to MessageBird to attempt // cancellation of the scheduled message. This is out of scope for this guide. logger.warn(`Delete operation called for appointment ${id} - MessageBird SMS cancellation not implemented.`); // Fetch the appointment first if you needed to cancel the message // const appointmentToDelete = await db.appointment.findUnique({ where: { id } }); // if (appointmentToDelete?.messageBirdId) { // // Call MessageBird API to cancel message ID appointmentToDelete.messageBirdId // } // Then delete from DB return db.appointment.delete({ where: { id }, }) }validateAppointmentDateTimewarning about server-local time zone parsing and recommending UTC/ISO 8601.messagebird.lookup.readasynchronously using Promises. Handles errors and checks if the number is mobile. Throws specific, user-friendly errors. Stores the E.164 formattednormalizedPhoneNumber.messagebird.messages.create, ensuringscheduledDatetimeis formatted as ISO 8601 (UTC recommended). Uses Promises for async handling.try...catchblocks. Logs detailed errors using Redwood's logger and throws user-friendly error messages.You've now implemented your core backend logic. Your API can accept booking requests, validate data and phone numbers, schedule SMS reminders via MessageBird, and save appointments to your database.
3. Build Your Frontend Interface
Create the React components and page for users to interact with your booking system.
Generate Your Page and Component: Use Redwood generators to create the page and a reusable form component.
This creates:
web/src/pages/BookingPage/BookingPage.tsx(and related files) accessible at the/bookingrouteweb/src/components/BookingForm/BookingForm.tsx(and related files)Implement Your Booking Form Component: Open
web/src/components/BookingForm/BookingForm.tsx. Use Redwood's form helpers for easier state management and validation handling.// web/src/components/BookingForm/BookingForm.tsx import { Form, FormError, Label, TextField, DatetimeLocalField, // Using DatetimeLocalField simplifies date/time input Submit, FieldError, useForm, } from '@redwoodjs/forms' import { navigate, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' import { logger } from 'src/lib/logger' // Use Redwood's web logger if needed // Consider adding a date library like date-fns for robust parsing/formatting // import { parseISO, format } from 'date-fns' // GraphQL Mutation Definition (must match the backend SDL) const CREATE_APPOINTMENT_MUTATION = gql` mutation CreateAppointmentMutation($input: CreateAppointmentInput!) { createAppointment(input: $input) { id # Request the ID upon successful creation } } ` // Constants for minimum time calculation clarity const MIN_HOURS_ADVANCE = 3; const MIN_MINUTES_BUFFER = 15; // Add buffer to ensure validation passes interface BookingFormProps { onSuccess?: () => void // Optional callback after successful submission } const BookingForm = ({ onSuccess }: BookingFormProps) => { const formMethods = useForm({ mode: 'onBlur' }) // Use onBlur validation mode const [create, { loading, error }] = useMutation( CREATE_APPOINTMENT_MUTATION, { onCompleted: (data) => { toast.success('Appointment booked successfully!') logger.info({ appointmentId: data.createAppointment.id }, 'Appointment created via form.') // Navigate to a confirmation page or call onSuccess callback navigate(routes.bookingSuccess({ id: data.createAppointment.id })) // Example: Navigate to a success page // onSuccess?.() // Alternative: Call callback if provided formMethods.reset() // Reset the form fields }, onError: (error) => { logger.error({ error }, 'Error booking appointment via form:') toast.error(`Booking failed: ${error.message}`) }, } ) const onSubmit = (data) => { // Redwood's DatetimeLocalField provides ISO string like "YYYY-MM-DDTHH:mm" // We need to split it for our backend mutation input which expects separate date and time strings. // WARNING: Native `new Date()` parsing can be unreliable across browsers/locales for formats // other than strict ISO 8601 with timezone (like the one from DatetimeLocalField). // Using a library like date-fns (e.g., parseISO) is more robust. try { const dateTime = new Date(data.appointmentDateTime); if (isNaN(dateTime.getTime())) { throw new Error('Invalid date/time selected.') } // NOTE: The following relies on specific string formats and might be fragile. // Using date library functions (e.g., date-fns format(dateTime, 'yyyy-MM-dd')) is preferred. const dateString = dateTime.toISOString().split('T')[0]; // Extracts YYYY-MM-DD from UTC ISO string const timeString = dateTime.toTimeString().split(' ')[0].substring(0, 5); // Extracts HH:mm from local time string const input = { name: data.name, treatment: data.treatment, number: data.number, date: dateString, time: timeString, } logger.debug({ input } , 'Submitting booking form data') create({ variables: { input } }) } catch (e) { logger.error({ error: e, formData: data}, 'Error processing form data before submission') toast.error(`Error processing input: ${e.message}`) } } // Helper to set min date/time for the input field const getMinDateTime = () => { const now = new Date(); // Add minimum advance time (3 hours from backend rule) plus a buffer (15 mins) // to ensure the selected time is valid when it reaches the server. now.setHours(now.getHours() + MIN_HOURS_ADVANCE); now.setMinutes(now.getMinutes() + MIN_MINUTES_BUFFER); // Format for datetime-local input requires 'YYYY-MM-DDTHH:mm' in local time. // Need to adjust for timezone offset if using Date methods that operate in UTC. const offset = now.getTimezoneOffset() // Offset in minutes from UTC const adjustedDate = new Date(now.getTime() - (offset*60*1000)) // Adjust to local time return adjustedDate.toISOString().slice(0,16) // Format to YYYY-MM-DDTHH:mm } return ( <div className="rw-form-wrapper"> <Form onSubmit={onSubmit} error={error} formMethods={formMethods}> <FormError error={error} wrapperClassName="rw-form-error-wrapper" /> <Label name="name" className="rw-label" errorClassName="rw-label rw-label-error"> Your Name </Label> <TextField name="name" className="rw-input" errorClassName="rw-input rw-input-error" validation={{ required: true }} /> <FieldError name="name" className="rw-field-error" /> <Label name="treatment" className="rw-label" errorClassName="rw-label rw-label-error"> Desired Treatment </Label> <TextField name="treatment" className="rw-input" errorClassName="rw-input rw-input-error" validation={{ required: true }} /> <FieldError name="treatment" className="rw-field-error" /> <Label name="number" className="rw-label" errorClassName="rw-label rw-label-error"> Mobile Phone Number (for SMS reminder) </Label> <TextField name="number" placeholder="e.g., +14155552671 or 4155552671" className="rw-input" errorClassName="rw-input rw-input-error" validation={{ required: true, // Basic pattern - backend validation via MessageBird is more reliable // pattern: { // value: /^\+?[1-9]\d{1,14}$/, // Simple E.164-like pattern // message: 'Please enter a valid phone number format.', // }, }} /> <FieldError name="number" className="rw-field-error" /> <Label name="appointmentDateTime" className="rw-label" errorClassName="rw-label rw-label-error"> Appointment Date and Time </Label> {/* Use datetime-local for combined input */} <DatetimeLocalField name="appointmentDateTime" className="rw-input" errorClassName="rw-input rw-input-error" validation={{ required: true, valueAsDate: true, // Ensure the value can be parsed as a Date // Custom validation could also be added here if needed }} min={getMinDateTime()} // Set minimum selectable date/time based on current time + buffer /> <FieldError name="appointmentDateTime" className="rw-field-error" /> <div className="rw-button-group"> <Submit disabled={loading} className="rw-button rw-button-blue"> {loading ? 'Booking…' : 'Book Appointment'} </Submit> </div> </Form> </div> ) } export default BookingForm@redwoodjs/forms,@redwoodjs/router,@redwoodjs/web, and@redwoodjs/web/toast. Added logger import. Commented outdate-fnsimport but noted its recommendation.CREATE_APPOINTMENT_MUTATIONmatching the backend SDL.useMutationHook: Sets up the mutation call, includingonCompleted(for success toast, logging, navigation, form reset) andonError(for error toast and logging) handlers.useFormHook: Initializes Redwood's form handling, setting validation mode toonBlur.onSubmitHandler:datetime-localinput string (data.appointmentDateTime) into aDateobject. Includes a warning about potential nativeDateparsing issues and recommends a library.Dateobject intoYYYY-MM-DDandHH:mmstrings as required by the backend mutation input. Includes a note about the fragility of string manipulation and preference for date library functions.inputobject for the mutation.createfunction fromuseMutation.try...catchblock for errors during date/time processing before the mutation is sent.getMinDateTimeHelper: Calculates the earliest selectable date/time for theDatetimeLocalFieldbased on the backend rule (3 hours) plus a buffer (15 minutes) to prevent validation failures due to timing differences. Formats the date correctly for theminattribute ofdatetime-local.<Form>,<FormError>,<Label>,<TextField>,<DatetimeLocalField>,<FieldError>, and<Submit>components.DatetimeLocalFieldfor combined date and time input, simplifying the UI.requiredvalidation. Commented out a basic phone number pattern, emphasizing backend validation is primary.minattribute onDatetimeLocalFieldusinggetMinDateTime.<Submit>button is disabled during loading.Implement Your Booking Page: Open
web/src/pages/BookingPage/BookingPage.tsx. This page simply renders theBookingFormcomponent.// web/src/pages/BookingPage/BookingPage.tsx import { MetaTags } from '@redwoodjs/web' import BookingForm from 'src/components/BookingForm/BookingForm' const BookingPage = () => { return ( <> <MetaTags title="Book Appointment" description="Schedule your appointment" /> <h1>Book Your Appointment</h1> <p>Fill out the form below to book your appointment. You'll receive an SMS reminder 3 hours before your scheduled time.</p> <BookingForm /> </> ) } export default BookingPageWhat This Does: Imports
MetaTagsfor SEO and theBookingForm. Renders a heading, descriptive text, and the form component.Add Your Routes: Ensure your routes are defined in
web/src/Routes.tsx. Thegenerate pagecommand should have added the/bookingroute. You might also want a success page route.// web/src/Routes.tsx import { Router, Route, Set } from '@redwoodjs/router' import MainLayout from 'src/layouts/MainLayout/MainLayout' // Example layout const Routes = () => { return ( <Router> {/* Add a simple success page component if desired */} {/* <Route path="/booking-success/{id:Int}" page={BookingSuccessPage} name="bookingSuccess" /> */} <Set wrap={MainLayout}> {/* Wrap pages in a layout if you have one */} <Route path="/booking" page={BookingPage} name="booking" /> {/* Add other routes here */} <Route notfound page={NotFoundPage} /> </Set> </Router> ) } export default RoutesbookingSuccesswhich theBookingFormattempts to navigate to. Generate and implementBookingSuccessPagefor this to work fully.Add Your Toaster: To display success/error messages from
toast, add the<Toaster />component, typically in your main layout file (e.g.,web/src/layouts/MainLayout/MainLayout.tsx).// web/src/layouts/MainLayout/MainLayout.tsx (Example) import { Toaster } from '@redwoodjs/web/toast' type MainLayoutProps = { children?: React.ReactNode } const MainLayout = ({ children }: MainLayoutProps) => { return ( <div className="main-layout"> <Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} /> <header>{/* Header content */}</header> <main>{children}</main> <footer>{/* Footer content */}</footer> </div> ) } export default MainLayoutYour frontend is now set up. Users can navigate to
/booking, fill out the form, and submit it to trigger your backend logic. Feedback is provided via toasts.4. Run and Test Your Application
Start Your Development Server: Run the Redwood development server – starts both the API and web sides with hot-reloading.
Access Your Application: Open your browser and navigate to
http://localhost:8910/booking(or the port specified in yourredwood.toml).Test Your Booking:
yarn rw prisma studio)Test Your Validation:
Next Steps: Enhance Your Appointment System
You've successfully built a RedwoodJS appointment booking system with automated SMS reminders powered by MessageBird. This implementation demonstrates full-stack development with phone validation, scheduled messaging, and database persistence.
Production Enhancements:
BookingSuccessPagecomponent with appointment summary and next stepsmessageBirdIdyarn rw generate auth ...) for personalized appointment historydate-fns-tzor Luxon for accurate scheduling across regions. Store all timestamps in UTCDATABASE_URL,MESSAGEBIRD_API_KEY, and other secretsRelated Resources: