0%

How I Built My Own Meeting Booking System: From Expensive SaaS to Custom Google Calendar Integration

10 min read

After getting tired of paying $15-30/month for booking systems like Calendly and Acuity, I decided to build my own. What started as a weekend project turned into a sophisticated booking system with Google Calendar integration, smart timezone handling, and a beautiful user interface. Here's the complete story of how I built it, the challenges I faced, and the code that powers my personal meeting scheduler.


The Problem: Booking Systems Are Expensive

As a freelance developer, I needed a simple way for clients to book consultation calls. The popular solutions were charging way too much:

  • Calendly: $8-16/month per user
  • Acuity Scheduling: $14-50/month
  • Square Appointments: $29-69/month

For a few meetings per month, these prices felt ridiculous. Plus, I wanted more control over the user experience and data. That's when I decided to build my own.

What I Built

My custom booking system includes:

  • Google Calendar Integration: Automatic event creation and conflict detection
  • Smart Timezone Handling: Displays times in user's local timezone while keeping my Ukraine timezone as the base
  • Meeting Types: Different durations for consultations, project discussions, and technical reviews
  • Real-time Availability: Checks Google Calendar in real-time to prevent double-bookings
  • Telegram Notifications: Instant notifications when someone books a meeting
  • Beautiful UI: Modern React interface with animations and responsive design

The Tech Stack I Chose

After evaluating different options, I settled on:

  • Frontend: Next.js 14 with React and TypeScript
  • Styling: Tailwind CSS with custom animations
  • Calendar: Google Calendar API with service account authentication
  • Forms: React Hook Form with Zod validation
  • Notifications: Telegram Bot API for instant alerts
  • Deployment: Vercel for seamless Next.js hosting

This stack gave me server-side rendering, type safety, and easy deployment while keeping costs minimal.


The Architecture: Keep It Simple

Instead of over-engineering, I chose a simple but effective architecture:

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   React Client  │───▶│  Next.js API     │───▶│ Google Calendar │
│   (booking-form)│    │  (/api/book)     │    │      API        │
└─────────────────┘    └──────────────────┘    └─────────────────┘
                                │
                                ▼
                       ┌──────────────────┐
                       │  Telegram Bot    │
                       │  (notifications) │
                       └──────────────────┘

No database needed! Google Calendar becomes my database, storing all booking information and handling conflicts automatically.


Challenge #1: Timezone Hell

The biggest challenge was handling timezones correctly. I live in Ukraine, but clients book from all over the world. Here's what I learned:

The Problem

  • Display times in the client's local timezone for convenience
  • Always book in Ukraine timezone (my working hours)
  • Prevent confusion and missed meetings
  • Handle edge cases like daylight saving time

The Solution

I created a comprehensive timezone utility:

// Always work with Ukraine timezone as the base
export const UKRAINE_TIMEZONE = 'Europe/Kyiv';

// Convert Ukraine time to client's local time for display
export const convertUkraineTimeToUserLocal = (
  ukraineTimeStr: string, 
  userTimezone: string, 
  dateStr: string
): string => {
  // Create Ukraine datetime string
  const ukraineTimeString = `${dateStr}T${ukraineTimeStr}:00`;
  
  // Convert from Ukraine timezone to UTC
  const utcDateTime = fromZonedTime(ukraineTimeString, UKRAINE_TIMEZONE);
  
  // Format in user's timezone for display
  return formatInTimeZone(utcDateTime, userTimezone, 'h:mm a');
};

The key insight: Always store times in the host's timezone, but display them in the user's timezone.


Challenge #2: Google Calendar Service Account Setup

Getting Google Calendar API working properly took longer than expected. Here's the setup:

1. Create Google Cloud Project

# Enable Calendar API
gcloud services enable calendar-json.googleapis.com

# Create service account
gcloud iam service-accounts create booking-system

2. Share Your Calendar

The tricky part: you must share your Google Calendar with the service account email:

  • Go to Google Calendar settings
  • Find the service account email (looks like booking-system@project.iam.gserviceaccount.com)
  • Share your calendar with "Make changes to events" permission

3. Environment Variables

GOOGLE_PROJECT_ID=your-project-id
GOOGLE_PRIVATE_KEY_ID=key-id
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
GOOGLE_CLIENT_EMAIL=booking-system@project.iam.gserviceaccount.com
GOOGLE_CLIENT_ID=client-id

The private key formatting was crucial - make sure to preserve the \n characters!


The API Route: Where the Magic Happens

The core of my system is a single Next.js API route at /api/book. Here's how it works:

1. Input Validation and Race Condition Prevention

export async function POST(request: NextRequest) {
  const body: BookingRequest = await request.json();
  
  // Validate required fields
  if (!body.name || !body.email || !body.selectedDate || !body.selectedTime) {
    return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
  }

  // Prevent bookings too close to current time
  if (isBookingTooSoon(body.selectedDate, body.selectedTime)) {
    return NextResponse.json(
      { error: 'Booking must be at least 2 hours in advance' }, 
      { status: 400 }
    );
  }
  
  // Check for double bookings right before creating event
  const hasConflict = await checkCalendarConflicts(body.selectedDate, body.selectedTime);
  if (hasConflict) {
    return NextResponse.json(
      { error: 'This time slot has already been booked.' }, 
      { status: 409 }
    );
  }
}

2. Google Calendar Integration

// Create service account authentication
const auth = new google.auth.GoogleAuth({
  credentials: {
    type: 'service_account',
    project_id: process.env.GOOGLE_PROJECT_ID,
    private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
    client_email: process.env.GOOGLE_CLIENT_EMAIL,
  },
  scopes: ['https://www.googleapis.com/auth/calendar'],
});

const calendar = google.calendar({ version: 'v3', auth });

// Create calendar event with proper timezone handling
const { startDateTime, endDateTime } = createCalendarEventTimes(
  body.selectedDate,
  body.selectedTime,
  body.duration
);

const event = {
  summary: `${MEETING_TYPE_LABELS[body.meetingType]} - ${body.name}`,
  description: `Meeting with ${body.name} (${body.email})`,
  start: {
    dateTime: startDateTime.toISOString(),
    timeZone: UKRAINE_TIMEZONE,
  },
  end: {
    dateTime: endDateTime.toISOString(),
    timeZone: UKRAINE_TIMEZONE,
  },
  reminders: {
    useDefault: false,
    overrides: [
      { method: 'email', minutes: 24 * 60 },
      { method: 'popup', minutes: 30 },
    ],
  },
};

await calendar.events.insert({
  calendarId: 'your-email@gmail.com',
  requestBody: event,
});

3. Instant Telegram Notifications

// Send notification to my Telegram
const telegramMessage = `
🗓️ NEW BOOKING
📋 ${MEETING_TYPE_LABELS[body.meetingType]}
📅 ${ukraineDate.toLocaleDateString()}
🕒 ${ukraineDate.toLocaleTimeString()} Ukraine Time
👤 ${body.name}
📧 ${body.email}
`;

await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    chat_id: TELEGRAM_CHAT_ID,
    text: telegramMessage,
  }),
});

This gives me instant notifications on my phone whenever someone books a meeting.


The Frontend: Beautiful and Functional

The React frontend handles three main states: date selection, time selection, and form submission.

Meeting Type Selection

const MEETING_TYPES = [
  { value: 'consultation', label: 'Initial Consultation (30 min)', duration: 30 },
  { value: 'project-discussion', label: 'Project Discussion (60 min)', duration: 60 },
  { value: 'technical-review', label: 'Technical Review (45 min)', duration: 45 },
];

Smart Time Display

The trickiest part was showing times correctly:

// Generate slots in Ukraine time (my working hours 9 AM - 9 PM)
for (let hour = 9; hour < 21; hour++) {
  for (let minute = 0; minute < 60; minute += 30) {
    const ukraineTimeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
    
    // Convert to user's local time for display
    const userLocalTime = convertUkraineTimeToUserLocal(ukraineTimeStr, userTimezone, dateStr);
    
    slots.push({
      time: ukraineTimeStr, // Keep original for backend
      displayTime: userLocalTime // Show user's local time
    });
  }
}

Real-time Availability Checking

When a user selects a date, the frontend calls the API to get real availability:

const handleDateSelect = async (date: Date) => {
  setIsCheckingAvailability(true);
  
  try {
    const response = await fetch(`/api/book?date=${dateString}`);
    const data = await response.json();
    setAvailableSlots(data.timeSlots);
  } catch (error) {
    // Fallback to default slots if API fails
    generateDefaultSlots();
  }
};

This prevents showing slots that are already booked.


Challenges I Faced and How I Solved Them

Problem 1: Race Conditions

Two people could book the same time slot simultaneously.

Solution: Check availability right before creating the calendar event:

// Check for conflicts with a fresh API call
const availabilityCheck = await calendar.events.list({
  calendarId: 'your-email@gmail.com',
  timeMin: startOfDay.toISOString(),
  timeMax: endOfDay.toISOString(),
});

const hasConflict = availabilityCheck.data.items.some(event => {
  // Check if requested time overlaps with existing event
  return (startDateTime < eventEnd && endDateTime > eventStart);
});

if (hasConflict) {
  return NextResponse.json(
    { error: 'Time slot already booked' }, 
    { status: 409 }
  );
}

Problem 2: Timezone Edge Cases

Users in different timezones seeing different available times.

Solution: Always generate slots based on my timezone (Ukraine), then convert for display:

// Times shown: "2:00 PM (6:00 PM Ukraine)"
<div>
  {userLocalTime}
  <div className="text-xs opacity-75">
    ({convertToAMPM(slot.time)} Ukraine)
  </div>
</div>

Problem 3: Mobile Experience

Needed the booking form to work perfectly on mobile devices.

Solution: Progressive form reveal and touch-friendly design:

{/* Only show contact fields after time is selected */}
{selectedDate && selectedTime && (
  <div className="grid md:grid-cols-2 gap-6">
    {/* Name and email inputs */}
  </div>
)}

The Results: Better Than Expected

After building my own booking system, here's what I gained:

Cost Savings

  • Before: $20-30/month for Calendly Pro
  • After: $0/month (just hosting costs ~$0 on Vercel)
  • Annual savings: $240-360

Better User Experience

  • Faster loading (optimized for my use case)
  • Perfect mobile experience
  • Custom branding that matches my website
  • Smart timezone handling that actually works

Complete Control

  • Can add features anytime (like different meeting types)
  • Full access to booking data
  • Custom analytics and tracking
  • Integration with my existing tools

The Numbers

Since launching 6 months ago:

  • 50+ meetings booked without any issues
  • Zero double bookings thanks to race condition handling
  • 100% mobile friendly - many clients book from phones
  • Instant notifications - I respond to bookings within minutes

What I'd Do Differently

If I were starting over:

  1. Start simpler: My first version had too many features. The core booking flow is what matters.
  2. Test timezones early: Spend more time testing with people in different timezones.
  3. Add analytics sooner: I wish I tracked conversion rates from the beginning.
  4. Consider SMS notifications: Telegram is great, but SMS might be more reliable.

Future Plans: NPM Package

I'm now working on packaging this into a reusable NPM module. The plan:

npm install @andriifurmanets/booking-system

Key features for the package:

  • Drop-in React components
  • Multiple calendar providers (not just Google)
  • Customizable themes and styling
  • Built-in timezone handling
  • TypeScript support

The goal is to help other developers avoid the subscription trap while getting a better booking experience than the commercial alternatives.


Frequently Asked Questions

How long did it take to build this booking system?

About 2 weeks of focused development. The first weekend was spent setting up Google Calendar integration, and the second week was spent on the frontend and edge cases.

What's the total cost to run this system?

Essentially free! Vercel hosting is free for my traffic level, Google Calendar API is free up to generous limits, and Telegram Bot API is completely free.

How do you handle no-shows or cancellations?

Currently manual - clients email me to reschedule. For the NPM package, I plan to add a cancellation link in confirmation emails that automatically removes the calendar event.

What about payment integration?

I don't collect payments through the booking system. I handle payments separately after the consultation. For others, integrating Stripe would be straightforward.

Can this scale to multiple team members?

The current version is designed for single-person use. For teams, you'd need to add user authentication and calendar selection. This is planned for the NPM package version.

How do you prevent spam bookings?

I use basic email validation and require a 2-hour advance booking. For higher-volume use cases, you might want to add CAPTCHA or require phone verification.

Would you recommend building your own vs using existing solutions?

If you're a developer and want complete control, absolutely! The time investment pays off quickly in cost savings and customization freedom. For non-technical users, existing solutions might be simpler.

Share this article

Ready to start your project?

Available for freelance projects and consultations

Get In Touch

© 2025 Andrii Furmanets. All rights reserved.