code examples
code examples
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-fnsor 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:
bashyarn create redwood-app redwood-messagebird-reminders --typescriptThis scaffolds a new RedwoodJS project with TypeScript configured in a directory named
redwood-messagebird-reminders. -
Navigate to Your Project Directory:
bashcd redwood-messagebird-reminders -
Install Your Initial Dependencies: RedwoodJS automatically runs
yarn installafter creation. To run it manually:bashyarn install -
Configure Your Environment Variables: RedwoodJS uses a
.envfile for environment variables. Create this file in your project root:bashtouch .envOpen
.envand add these variables. Get the values in subsequent steps.dotenv# .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. +12025550181Why 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:bashyarn 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:
bashgit 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: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 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.bashyarn rw prisma migrate dev- Enter a name for the migration when prompted. Something like
create appointment modelworks well - Why Use
migrate 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 model
- Enter a name for the migration when prompted. Something like
-
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.
bashyarn rw generate sdl Appointment --crudThis 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 structure
-
Customize 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).typescript// 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:
- Modified
CreateAppointmentInputto acceptnumber,date, andtimeas strings – reflects typical HTML form input. Added comment suggesting ISO 8601 - Added
@skipAuthtocreateAppointmentmutation to allow unauthenticated users to book. Keep@requireAuthfor other operations
- Modified
-
Implement Your Service Logic: This is where your core booking and scheduling logic lives. Open
api/src/services/appointments/appointments.tsand modify thecreateAppointmentfunction significantly.typescript// 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 }, }) }- Explanation:
- Initialization: Added comment clarifying the implication of not failing fast on SDK init error.
- Validation: Added comments to
validateAppointmentDateTimewarning about server-local time zone parsing and recommending UTC/ISO 8601. - Lookup: Calls
messagebird.lookup.readasynchronously using Promises. Handles errors and checks if the number is mobile. Throws specific, user-friendly errors. Stores the E.164 formattednormalizedPhoneNumber. - Scheduling: Calculates the reminder time. Creates parameters for
messagebird.messages.create, ensuringscheduledDatetimeis 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...catchblocks. 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.
- Explanation:
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.
bashyarn rw generate page Booking /booking yarn rw generate component BookingFormThis 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.typescript// 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- Explanation:
- Imports: Includes necessary components from
@redwoodjs/forms,@redwoodjs/router,@redwoodjs/web, and@redwoodjs/web/toast. Added logger import. Commented outdate-fnsimport but noted its recommendation. - Mutation: Defines the
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:- Retrieves data from the form state.
- Parses the
datetime-localinput string (data.appointmentDateTime) into aDateobject. Includes a warning about potential nativeDateparsing issues and recommends a library. - Splits the
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. - Constructs the
inputobject for the mutation. - Calls the
createfunction fromuseMutation. - Includes a
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 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
DatetimeLocalFieldfor combined date and time input, simplifying the UI. - Includes basic
requiredvalidation. Commented out a basic phone number pattern, emphasizing backend validation is primary. - Sets the
minattribute onDatetimeLocalFieldusinggetMinDateTime.
- Uses
- Submission: The
<Submit>button is disabled during loading.
- Imports: Includes necessary components from
- Explanation:
-
Implement Your Booking Page: Open
web/src/pages/BookingPage/BookingPage.tsx. This page simply renders theBookingFormcomponent.typescript// 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.typescript// 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 Routes- Note: Added a commented-out example route
bookingSuccesswhich theBookingFormattempts to navigate to. Generate and implementBookingSuccessPagefor this to work fully.
- Note: Added a commented-out example route
-
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).typescript// 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 MainLayout
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.
bashyarn rw dev -
Access Your Application: Open your browser and navigate to
http://localhost:8910/booking(or the port specified in yourredwood.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
BookingSuccessPagecomponent 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-tzor 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
Related Resources: