Supabase + Next.js: Building a Real-Time Booking System

Build a real-time restaurant booking system with Supabase and Next.js. Covers atomic booking logic, concurrent request handling, and live availability updates.
Why Most Booking Systems Let Restaurants Down
Picture this: a customer books a table for Saturday night through your website. Moments later, someone else books the same slot over the phone. Neither system knows about the other, and you have a double-booking crisis on your busiest night of the week.
This isn't hypothetical. It's something we encounter regularly when auditing restaurant and hospitality websites. The booking logic is either bolted onto a third-party widget that charges per-booking fees, or it's a simple contact form that sends an email and hopes for the best.
A properly built real-time booking system solves all of this. In this guide, we'll walk through how to build one using Next.js and Supabase — a combination that handles real-time updates, prevents double-bookings, and scales effortlessly from a single restaurant to a multi-venue group.
What You'll Build
By the end of this guide, you'll understand how to build a booking system that:
- Shows live table availability as customers browse
- Prevents two customers from booking the same slot simultaneously
- Sends instant confirmation emails
- Gives restaurant staff a real-time management dashboard
- Handles cancellations and modifications gracefully
We'll be using Next.js 14 with the App Router and Supabase for the database, authentication, and real-time subscriptions.
Step 1: Database Design
Good booking systems start with good data models. Here's the core schema we use for most restaurant clients.
The bookings Table
create table bookings (
id uuid default gen_random_uuid() primary key,
restaurant_id uuid references restaurants(id),
customer_name text not null,
customer_email text not null,
customer_phone text,
booking_date date not null,
booking_time time not null,
party_size integer not null,
status text default 'pending' check (status in ('pending', 'confirmed', 'cancelled')),
notes text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);The time_slots Table
Rather than calculating availability on the fly, pre-populate available time slots. This makes availability queries fast and prevents race conditions:
create table time_slots (
id uuid default gen_random_uuid() primary key,
restaurant_id uuid references restaurants(id),
date date not null,
time time not null,
max_capacity integer not null,
booked_count integer default 0,
constraint unique_slot unique (restaurant_id, date, time)
);The booked_count column is the key to preventing double-bookings. We'll use a database function to increment it atomically, so two simultaneous requests can never both succeed when only one seat remains.
Preventing Double-Bookings with a Database Function
This is where most DIY booking systems fall apart. If two requests arrive simultaneously, both might read booked_count = 3 (with a capacity of 4) and both attempt to book. You end up with 5 bookings against a capacity of 4 — and an awkward conversation on Saturday night.
The solution is a PostgreSQL function that checks and updates atomically:
create or replace function create_booking(
p_restaurant_id uuid,
p_date date,
p_time time,
p_party_size integer,
p_customer_name text,
p_customer_email text
) returns uuid as $$
declare
v_slot_id uuid;
v_booking_id uuid;
begin
-- Lock the slot row for this transaction
select id into v_slot_id
from time_slots
where restaurant_id = p_restaurant_id
and date = p_date
and time = p_time
and (max_capacity - booked_count) >= p_party_size
for update;
if v_slot_id is null then
raise exception 'No availability for this time slot';
end if;
-- Update the slot count
update time_slots
set booked_count = booked_count + 1
where id = v_slot_id;
-- Create the booking record
insert into bookings (
restaurant_id, customer_name, customer_email,
booking_date, booking_time, party_size
)
values (
p_restaurant_id, p_customer_name, p_customer_email,
p_date, p_time, p_party_size
)
returning id into v_booking_id;
return v_booking_id;
end;
$$ language plpgsql;The FOR UPDATE clause locks the row for the duration of the transaction, meaning concurrent requests queue up rather than race. Only one succeeds; the other receives a clear error message it can relay to the customer.
Step 2: Setting Up Supabase in Next.js
Install the required packages:
npm install @supabase/supabase-js @supabase/ssrCreate a Supabase client for server components:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export function createClient() {
const cookieStore = cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {}
}
}
}
)
}Step 3: The Booking Form (Server Action)
With the Next.js App Router, you can handle form submissions using a Server Action — no separate API route needed. This keeps sensitive database logic server-side and removes an entire network round-trip from the client:
// app/booking/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function createBooking(formData: FormData) {
const supabase = createClient()
const { data, error } = await supabase.rpc('create_booking', {
p_restaurant_id: formData.get('restaurantId') as string,
p_date: formData.get('date') as string,
p_time: formData.get('time') as string,
p_party_size: parseInt(formData.get('partySize') as string),
p_customer_name: formData.get('name') as string,
p_customer_email: formData.get('email') as string,
})
if (error) {
if (error.message.includes('No availability')) {
return {
success: false,
error: 'Sorry, that time slot is no longer available. Please choose another time.'
}
}
return { success: false, error: 'Something went wrong. Please try again.' }
}
revalidatePath('/booking')
return { success: true, bookingId: data }
}The error handling matters here. If two customers race to book the same slot, one gets a clear, actionable message rather than a generic server error. That's the difference between a customer trying another time and a customer leaving your site entirely.
Step 4: Real-Time Availability with Supabase Subscriptions
This is where the system becomes genuinely impressive. Supabase's real-time feature uses PostgreSQL's logical replication to push database changes to connected clients via WebSockets — no polling, no manual refreshes required.
Here's a client component that shows live availability:
// components/AvailabilityGrid.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
interface TimeSlot {
id: string
time: string
max_capacity: number
booked_count: number
}
export function AvailabilityGrid({
restaurantId,
date
}: {
restaurantId: string
date: string
}) {
const [slots, setSlots] = useState<TimeSlot[]>([])
useEffect(() => {
supabase
.from('time_slots')
.select('*')
.eq('restaurant_id', restaurantId)
.eq('date', date)
.then(({ data }) => setSlots(data ?? []))
const channel = supabase
.channel('availability')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'time_slots',
filter: 'restaurant_id=eq.' + restaurantId,
},
(payload) => {
setSlots(prev =>
prev.map(slot =>
slot.id === payload.new.id
? { ...slot, ...payload.new }
: slot
)
)
}
)
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [restaurantId, date])
return (
<div className="grid grid-cols-3 gap-2">
{slots.map(slot => {
const available = slot.max_capacity - slot.booked_count
const isFull = available === 0
return (
<button
key={slot.id}
disabled={isFull}
className={isFull
? 'p-3 rounded bg-gray-100 text-gray-400 cursor-not-allowed'
: 'p-3 rounded bg-green-50 text-green-700 hover:bg-green-100'
}
>
{slot.time} {isFull ? '(Full)' : '(' + available + ' left)'}
</button>
)
})}
</div>
)
}When a booking goes through — whether via the website, by a staff member updating the dashboard after a phone call, or from a walk-in — every customer currently viewing that page sees the availability update in real-time. No refreshing required.
We built this for a restaurant client who was previously running a static availability calendar updated manually each morning. Within two weeks of going live, they reported zero double-booking incidents — down from a near-weekly occurrence.
Step 5: Row-Level Security
Never skip this step. Supabase's Row-Level Security (RLS) ensures customers can only see their own bookings, and staff can access everything they need:
-- Enable RLS on bookings table
alter table bookings enable row level security;
-- Customers can only view their own bookings
create policy "customers_view_own" on bookings
for select using (customer_email = auth.jwt() ->> 'email');
-- Staff can view all bookings for their restaurant
create policy "staff_view_restaurant_bookings" on bookings
for select using (
exists (
select 1 from staff_members
where user_id = auth.uid()
and restaurant_id = bookings.restaurant_id
)
);
-- Only the system (service role) can insert bookings
create policy "system_insert_bookings" on bookings
for insert with check (false);That last policy is important: it prevents direct client-side inserts, forcing all booking creation through the database function called from your Server Action — which runs with service role privileges. This is defence in depth.
Key Takeaways: Building a Robust Booking System
- **Use a database function with
FOR UPDATE** for booking creation — application-level availability checks are not safe under concurrent load - Pre-populate time slots rather than calculating availability dynamically — faster queries and simpler locking semantics
- Server Actions in Next.js App Router keep database logic server-side without API route boilerplate
- Enable Supabase Realtime for the
time_slotstable in your project dashboard before subscribing — it's off by default - Always enable RLS on every Supabase table — it's the primary security model and should be non-negotiable
- Store timestamps in UTC, display in local time — use
date-fns-tzfor timezone conversions in the UK hospitality context
Common Pitfalls to Avoid
Ignoring timezone offsets. A 7pm booking in London during summer (BST, UTC+1) stored naively as 7pm UTC will appear as 8pm in the database. Store everything in UTC with proper timezone awareness, and convert to the venue's local time for display only.
No confirmation email. A booking without an immediate confirmation email loses customer trust instantly. Connect a Supabase Database Webhook to trigger a confirmation email via Resend or SendGrid the moment a booking row is inserted. This takes roughly two hours to implement and should be treated as mandatory.
No cancellation flow. Customers cancel — often the night before at 11pm. If they cannot self-cancel via a link in their confirmation email, they'll phone, and someone has to take that call. Build the cancellation flow from day one.
Not handling the slot-fills-between-load-and-submit edge case. Your availability grid shows a slot as available when the customer loads the page, but it might fill in the seconds before they submit. The database function handles this gracefully — ensure your UI surfaces the "no longer available" error message clearly rather than failing silently.
Ready to Build This for Your Business?
A system like this typically takes us two to three weeks to design, build, test, and deploy for a hospitality client — complete with a staff management dashboard, automated confirmation and reminder emails, and full integration into an existing Next.js website.
If you're currently relying on a third-party booking widget with per-reservation fees, or a system that doesn't reflect your brand or integrate cleanly with the rest of your site, the economics of a custom build often work out favourably within the first year.
We've helped clients move from manual diary systems to fully automated booking flows, cutting no-show rates by over 30% through automated day-before reminder emails, and eliminating several hours of weekly admin in the process.
Get in touch and we'll give you an honest assessment of what's involved and whether it makes financial sense for your operation — no obligation, no sales pressure.
Need help implementing this?
We build high-performance websites and automate workflows for ambitious brands. Let's talk about how we can help your business grow.
More Articles

How to Add AI Chatbots to Your Website Without Hurting Page Speed
AI chatbots can transform customer engagement — but poorly implemented, they destroy your Core Web Vitals. Here's how to add them the right way.

How to Fix Cumulative Layout Shift (CLS) on Any Website
High CLS scores hurt your Google rankings and frustrate users. Here's how to diagnose and fix every major source of layout shift on any website.