Building a Hotel Reservation System: Architecture & Lessons
Building a hotel reservation system isn't just about booking rooms—it's about orchestrating availability, payments, and guest experiences in real-time. At Axiosware, we've learned this through building platforms like Isla Hotel, which achieved a 68% reduction in front-desk calls in just 6 weeks.
Key Takeaways
- ✓ Real-time availability requires optimistic locking and cache invalidation strategies
- ✓ Payment webhooks must handle race conditions and idempotency
- ✓ Multi-tenant architecture scales better than single-property designs
- ✓ Mobile-first booking flows convert 2.3x better than desktop-only
The Core Challenge: Availability is Everything
The single hardest problem in hotel reservation systems is managing availability across multiple channels. A guest books on your website, then calls to modify, then a third-party OTA (Online Travel Agency) pushes a cancellation. Your database needs to handle all of this without double-bookings.
We solved this with a combination of database-level optimistic locking and Redis-based caching:
-- Drizzle ORM schema with optimistic locking
export const rooms = pgTable('rooms', {
id: uuid('id').primaryKey(),
hotelId: uuid('hotel_id').notNull(),
roomNumber: varchar('room_number').notNull(),
capacity: integer('capacity').notNull(),
basePrice: decimal('base_price').notNull(),
version: integer('version').notNull().default(0), // Optimistic lock
});
export const bookings = pgTable('bookings', {
id: uuid('id').primaryKey(),
roomId: uuid('room_id').notNull(),
guestId: uuid('guest_id').notNull(),
checkIn: timestamp('check_in').notNull(),
checkOut: timestamp('check_out').notNull(),
status: varchar('status').notNull(), // pending, confirmed, cancelled
version: integer('version').notNull().default(0),
totalPrice: decimal('total_price').notNull(),
});
The critical piece is the booking transaction that checks version numbers:
async function createBooking(roomId: string, guestId: string, checkIn: Date, checkOut: Date) {
const bookingId = crypto.randomUUID();
return db.transaction(async (tx) => {
// Check availability with optimistic lock
const room = await tx
.select()
.from(rooms)
.where(eq(rooms.id, roomId))
.forUpdate() // Row-level lock
.limit(1);
if (!room.length) throw new Error('Room not found');
// Check for overlapping bookings
const conflicts = await tx
.select()
.from(bookings)
.where(
and(
eq(bookings.roomId, roomId),
eq(bookings.status, 'confirmed'),
or(
and(
gte(bookings.checkIn, checkIn),
lte(bookings.checkIn, checkOut)
),
and(
gte(bookings.checkOut, checkIn),
lte(bookings.checkOut, checkOut)
),
and(
lte(bookings.checkIn, checkIn),
gte(bookings.checkOut, checkOut)
)
)
)
);
if (conflicts.length > 0) {
throw new Error('Room not available for selected dates');
}
// Create booking with version increment
await tx.insert(bookings).values({
id: bookingId,
roomId,
guestId,
checkIn,
checkOut,
status: 'pending',
version: 0,
totalPrice: '0',
});
return bookingId;
});
}
Payment Processing: Don't Lose Money on Failed Bookings
Stripe webhooks are where reservation systems go to die if you don't handle them correctly. A guest books a room, gets charged, but the webhook fails. Do you refund? Do you hold the booking? The answer is: idempotency keys and webhook retry logic.
Stripe Webhook Handler Pattern
Every webhook endpoint must be idempotent—processing the same event twice should have no side effects:
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook signature invalid', { status: 400 });
}
// Idempotency: check if already processed
const existing = await db.query.bookings.findFirst({
where: eq(bookings.stripeEventId, event.id),
});
if (existing) {
return new Response('Already processed', { status: 200 });
}
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await confirmBooking(paymentIntent.metadata.bookingId);
break;
case 'payment_intent.payment_failed':
await cancelPendingBooking(event.data.object.id);
break;
}
// Mark as processed
await db
.update(bookings)
.set({ stripeEventId: event.id })
.where(eq(bookings.id, event.data.object.metadata.bookingId));
return new Response('OK', { status: 200 });
}
Case Study: Isla Hotel
Case Study: Isla Hotel
Challenge: A boutique hotel chain was drowning in phone calls for basic reservations. Front-desk staff spent 40% of their time answering booking questions instead of guest service.
Solution: We built a complete hotel reservation platform with:
- Real-time availability calendar with 500ms response times
- Mobile-first booking flow with Apple Pay and Google Pay
- Automated email/SMS confirmations via Resend and Twilio
- Admin dashboard for staff to manage bookings and room status
Results: 68% reduction in front-desk calls, 4.5x increase in online bookings, and $127K additional revenue in the first quarter post-launch.
Multi-Tenant Architecture for Hotel Chains
If you're building for a single property, a simple database works. But hotel chains need multi-tenant isolation with shared infrastructure. We use a hybrid approach:
Architecture Decision: Shared Database, Row-Level Security
Each hotel gets its own schema within a shared PostgreSQL database. This gives us:
- ✓ Isolated data per property (guest A can't see hotel B's bookings)
- ✓ Shared infrastructure costs (one database cluster for all hotels)
- ✓ Easy cross-property reporting for chain owners
- ✓ Simplified backups and scaling
The database schema includes tenant isolation:
export const hotels = pgTable('hotels', {
id: uuid('id').primaryKey(),
name: varchar('name').notNull(),
slug: varchar('slug').notNull().unique(),
address: jsonb('address').notNull(),
timezone: varchar('timezone').notNull(), // For availability calculations
commissionRate: decimal('commission_rate').notNull(), // Platform fee %
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export const bookings = pgTable('bookings', {
id: uuid('id').primaryKey(),
hotelId: uuid('hotel_id').notNull().references(() => hotels.id),
// ... rest of booking fields
});
Mobile-First Booking Flows
Our data shows that mobile bookings convert 2.3x better than desktop. The key is reducing friction:
- Guest recognition: Allow guests to book with just email—no account creation required. We send a magic link for future bookings.
- Apple Pay/Google Pay: One-tap payment reduces checkout abandonment by 35%.
- Offline-first caching: Guests can browse availability even with spotty hotel Wi-Fi. The app syncs when connection is restored.
- Push notifications: Check-in reminders, room-ready alerts, and checkout links keep guests engaged without phone calls.
Lessons from Production
After shipping multiple hospitality platforms, here are the hard-won lessons:
1. Timezones Break Everything
Guests in New York booking a Los Angeles hotel will get confused if you show availability in their local time vs. hotel time. We store all times in UTC in the database, but display in the hotel's timezone for availability and the guest's timezone for confirmations.
2. Cancellation Policies Need Flexibility
Different properties have different policies: 24-hour, 48-hour, 7-day, non-refundable. We built a policy engine that calculates refund amounts based on cancellation timing:
function calculateRefund(booking: Booking, cancellationTime: Date): Refund {
const policy = getPolicy(booking.hotelId);
const timeUntilCheckIn = booking.checkIn.getTime() - cancellationTime.getTime();
if (policy.type === 'non_refundable') {
return { amount: '0', reason: 'non_refundable' };
}
if (timeUntilCheckIn < policy.gracePeriodMs) {
return { amount: booking.totalPrice, reason: 'full_refund' };
}
if (timeUntilCheckIn < policy.fullChargeThresholdMs) {
return { amount: '0', reason: 'one_night_charge' };
}
return { amount: '0', reason: 'no_refund' };
}
3. OTA Integrations Are a Nightmare
Booking.com, Expedia, Airbnb—they all have different APIs, different cancellation rules, different payment flows. We built a unified OTA adapter pattern that normalizes these differences:
interface OTAAdapter {
syncAvailability(rooms: Room[]): Promise;
syncBookings(bookings: ExternalBooking[]): Promise;
cancelBooking(externalId: string): Promise;
}
class BookingComAdapter implements OTAAdapter {
async syncAvailability(rooms: Room[]) {
// Map Axiosware room IDs to Booking.com inventory IDs
// Handle rate limits and batch updates
}
}
class ExpediaAdapter implements OTAAdapter {
async syncAvailability(rooms: Room[]) {
// Different API, different payload structure
}
}
Tech Stack That Scales
The Stack
Frontend: Next.js 14 with App Router and Server Components for fast initial loads
Mobile: React Native with Expo for iOS and Android from one codebase
Backend: Node.js API routes + Supabase for PostgreSQL
Payments: Stripe with webhooks for all payment events
Email: Resend with React Email for transactional messages
Hosting: Vercel for automatic scaling and global CDN
Analytics: PostHog for product analytics and feature flags
When NOT to Build Custom
Honesty matters: sometimes a hotel chain should use existing software instead of building custom. Here's when we recommend off-the-shelf:
Use Existing Software If:
- ✓ You're a single-property boutique hotel with < 20 rooms
- ✓ You don't need custom integrations with property management systems
- ✓ Your budget is under $10K
- ✓ You're okay with monthly SaaS fees vs. one-time build cost
For these cases, we recommend platforms like Cloudbeds or Little Hotelier. But if you need custom features, multi-property support, or want to own your data—custom builds make sense.
The Bottom Line
Building a hotel reservation system is hard because you're competing with years of optimization from established players. But the opportunity is real: hospitality software is still stuck in the past, and guests expect modern experiences.
At Axiosware, we've learned that the key differentiators are:
- ✓ Real-time availability that actually works (no double-bookings)
- ✓ Payment processing that doesn't lose money on edge cases
- ✓ Mobile-first design that converts better than desktop
- ✓ Multi-tenant architecture that scales from 1 property to 100
If you're a hotel chain or property management company looking to modernize your booking system, we can help. Our Growth Engine package includes multi-platform builds (web + iOS + Android) with all the features discussed here.
Ready to Build?
Whether you're a single-property owner or a hotel chain, we can build a reservation system that reduces front-desk calls, increases online bookings, and gives you 100% ownership of your platform.
Start a ProjectWant to see more examples? Check out our case studies including Isla Hotel, Lefty's Cheesesteaks, and other successful launches.
Tags
Want More Engineering Insights?
Get startup architecture patterns, AI development techniques, and product launch strategies delivered to your inbox.
Join the Axiosware Newsletter
Weekly insights for founders and technical leaders
We respect your privacy. Unsubscribe at any time.
