code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / redwoodjs

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:

  1. User booking interface – React-based form where customers book appointments with name, service type, phone number, and preferred time
  2. Phone number validation – Real-time verification using MessageBird's Lookup API to ensure mobile numbers are valid and reachable
  3. Automated SMS reminders – Scheduled messages sent via MessageBird Messages API at predefined intervals (e.g., 3 hours before appointments)
  4. Database persistence – Prisma ORM integration for storing appointments, reminder schedules, and message tracking
  5. 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:

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

  1. Create Your RedwoodJS App: Open your terminal and run the RedwoodJS create command:

    bash
    yarn create redwood-app redwood-messagebird-reminders --typescript

    This scaffolds a new RedwoodJS project with TypeScript configured in a directory named redwood-messagebird-reminders.

  2. Navigate to Your Project Directory:

    bash
    cd redwood-messagebird-reminders
  3. Install Your Initial Dependencies: RedwoodJS automatically runs yarn install after creation. To run it manually:

    bash
    yarn install
  4. 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.

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

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

  6. Make Your Initial Git Commit (Recommended): Initialize a Git repository and make your first commit:

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

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

  2. 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
  3. 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
  4. 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.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 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
  5. 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.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 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.

  1. Generate Your Page and Component: Use Redwood generators to create the page and a reusable form component.

    bash
    yarn rw generate page Booking /booking
    yarn rw generate component BookingForm

    This creates:

    • web/src/pages/BookingPage/BookingPage.tsx (and related files) accessible at the /booking route
    • web/src/components/BookingForm/BookingForm.tsx (and related files)
  2. 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 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.
  3. Implement Your Booking Page: Open web/src/pages/BookingPage/BookingPage.tsx. This page simply renders the BookingForm component.

    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 BookingPage

    What This Does: Imports MetaTags for SEO and the BookingForm. Renders a heading, descriptive text, and the form component.

  4. 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.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 bookingSuccess which the BookingForm attempts to navigate to. Generate and implement BookingSuccessPage for this to work fully.
  5. 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

  1. Start Your Development Server: Run the Redwood development server – starts both the API and web sides with hot-reloading.

    bash
    yarn rw dev
  2. Access Your Application: Open your browser and navigate to http://localhost:8910/booking (or the port specified in your redwood.toml).

  3. 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
  4. 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

Related Resources: