Venue Booking
A full-stack hall and venue booking system where guests submit requests and an admin reviews and approves or rejects them (no guest accounts required). Built with Next.js 16, TypeScript, Tailwind CSS, Supabase, React Hook Form, and Zod.
The app is live at venue-booking-eight.vercel.app and deployed on Vercel.
How it works
The guest flow is deliberately frictionless. A visitor lands on the page, browses the venue details (capacity, amenities, time slot pricing), fills in a booking form, and receives a reference_number on confirmation. No signup, no account. The reference number is their only identifier.
The admin side is protected behind a login at /login. Once authenticated, the admin can view all bookings and approve or reject any that are pending. Approved and rejected bookings are final and cannot be re-actioned. The admin also controls venue pricing (time slots and guest packages) from a settings page.
Architecture decisions
No guest accounts
The biggest UX call. Guests book by form only. The reference_number (format: VB-{timestamp36}-{4chars}) acts as a receipt. Removing signup friction was the point; nobody wants to create an account to book a hall for one event.
Two Supabase clients
Supabase generates a REST API directly from your database tables. By default, anyone with your public key can query those tables. Row Level Security (RLS) is a PostgreSQL feature that locks this down at the database level by attaching access rules to each table. For example: "only the service role can SELECT from bookings." Even if someone gets the public key and hits the Supabase API directly, they get nothing back.
In this project there are two clients with different privilege levels:
| Client | File | Key | Bypasses RLS |
|---|---|---|---|
createClient() |
lib/supabase/server.ts |
Anon (public) | No |
createAdminClient() |
lib/supabase/admin.ts |
Service role (secret) | Yes |
The anon key is exposed to the browser (NEXT_PUBLIC_ prefix). Because bookings SELECT is restricted to the service role in RLS, a guest with the anon key cannot enumerate booking records by querying Supabase directly. All booking reads in API routes use createAdminClient() instead.
The pattern to remember: anon key for public/guest operations, service role for anything privileged.
Atomic booking to prevent race conditions
The biggest technical challenge: two guests submitting for the same venue and date simultaneously could both pass an availability check and both get inserted, resulting in a double-booking.
The fix is a PostgreSQL stored function called create_booking_atomic(). Instead of the app doing a check-then-insert in two separate steps, the entire logic lives inside a single database function. Supabase lets you call these functions remotely via RPC (Remote Procedure Call) — essentially calling a database function as if it were an API endpoint, rather than writing raw SQL from the app.
Inside the function, FOR UPDATE locks the relevant rows before checking availability:
SELECT * FROM bookings
WHERE venue_id = p_venue_id AND date = p_date
FOR UPDATE; -- locks these rows until the transaction ends
Two simultaneous requests queue at the lock. The second sees the first's inserted row and gets BOOKING_CONFLICT back. The API returns 409. Double-bookings are impossible at the database level, not just the application level.
The function uses SECURITY DEFINER so it runs with database-owner privileges regardless of who calls it. This matters because the anon client calls it, but the bookings table blocks direct inserts from the anon role.
Server-side price derivation
The client never sends prices in the booking form. On submission, the server re-fetches the venue's time_slots and pax_packages, matches by label, and derives the price itself. Price snapshots (slot_price, pax_price, total_price) are stored on the booking row at creation time, so the historical price is locked in even if the admin later changes the pricing.
This prevents client-side price tampering.
proxy.ts instead of middleware.ts
Next.js 16 renamed middleware.ts to proxy.ts with a named proxy export. The proxy runs on every /admin/** request and does two things: redirects unauthenticated users to /login, and refreshes the Supabase session token if the access token has expired but the refresh token is still valid. Without this refresh step, sessions would silently expire after roughly one hour.
Each API route also independently validates auth. The proxy is a first line of defence, not the only one.
Zod env validation at startup
lib/env.ts validates all environment variables with Zod at module load time. If a required variable is missing, the app throws immediately at startup with a clear error, not a cryptic failure mid-request. A useful pattern for any project with non-trivial env requirements.
Emails are fire-and-forget
Email delivery (via Resend) does not block or fail a booking request. The booking is committed first; the email fires after. If Resend is down or the API key is missing, the error is logged server-side and the user never notices. Emails are fully optional; the app runs without RESEND_API_KEY set.
In-memory rate limiting
lib/rate-limit.ts uses a Map for IP-based rate limiting. Simple and works for single-instance deployments. The known limitation: in a multi-instance (serverless/edge) environment, each instance has its own map, so limits don't apply globally. The interface (checkRateLimit, rateLimitHeaders) is stable, so swapping in a Redis-backed implementation wouldn't change callers.
Audit trail
Every admin action (pending to approved/rejected) writes a row to booking_status_history recording the before/after status and the admin's email. The table is service-role only and never exposed via the API. Useful for accountability; you can always see who approved what and when.
Challenges & what I learned
Supabase RLS is easy to misconfigure
Writing RLS policies that feel correct but actually leak data is a subtle trap. The lesson: always test with the anon key directly against the Supabase REST API, not just through the app, to confirm what's actually exposed.
Middleware rename caught me off guard
Discovering that Next.js 16 renamed middleware.ts to proxy.ts mid-build was an annoying surprise. Worth checking the Next.js changelog carefully before assuming API stability across major versions.
Atomic functions need explicit grants
SECURITY DEFINER alone isn't enough. The anon role still needs EXECUTE permission on the function:
GRANT EXECUTE ON FUNCTION create_booking_atomic(...) TO anon;
Missing this caused silent failures that looked like RLS blocks at first glance.
Zod v4 has breaking changes from v3
Several Zod APIs changed between v3 and v4. .strip() behaviour, error formatting, and some method names shifted. Worth reading the migration guide before assuming v3 knowledge transfers directly.
Final thought
The project ended up being a good exercise in thinking about trust boundaries: what the client is allowed to send, what only the server should compute, and what only the database should enforce. Each layer has a job — Zod validates shape, the API derives prices, and Postgres guarantees atomicity. When those concerns bleed into the wrong layer, bugs and security issues follow.
The live demo is at venue-booking-eight.vercel.app. Admin credentials are in the repo README.