Build a vehicle rental platform with V0 featuring fleet browsing by date range, PostgreSQL EXCLUDE constraint for double-booking prevention, Stripe Payment Intent for deposits, and an admin fleet management dashboard. You'll create availability checking via Supabase RPC, date-range booking with Calendar, and reservation management — all in about 1-2 hours.
What you're building
Car rental agencies and peer-to-peer vehicle sharing platforms need a system where customers browse available vehicles, select dates, and book without conflicts. The critical challenge is preventing double-bookings — two customers reserving the same car for overlapping dates.
V0 generates the fleet browsing UI, booking forms, and admin dashboard from prompts. Supabase stores vehicles, bookings, and reviews. PostgreSQL's EXCLUDE constraint with gist index guarantees no overlapping bookings at the database level. Stripe handles payment deposits.
The architecture uses Server Components for the fleet page, a Supabase RPC function for availability queries, Server Actions for booking management, and an API route for Stripe Payment Intent creation.
Final result
A vehicle rental platform with fleet browsing, date-range availability checking, double-booking prevention, Stripe payment deposits, and admin fleet management.
Tech stack
Prerequisites
- A V0 account (Premium recommended for Supabase + Stripe integrations)
- A Supabase project with the btree_gist extension enabled (free tier works)
- A Stripe account with test mode API keys (publishable + secret)
- No additional API keys or accounts needed
Build steps
Set up the vehicles, bookings, and reviews database schema
Open V0 and create a new project. Use the Connect panel to add Supabase. Create the vehicles, bookings, and reviews tables. The key is the EXCLUDE constraint on bookings that uses PostgreSQL's range exclusion to prevent overlapping date ranges for the same vehicle.
1-- Enable btree_gist for EXCLUDE constraints2CREATE EXTENSION IF NOT EXISTS btree_gist;34CREATE TABLE vehicles (5 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),6 make text NOT NULL,7 model text NOT NULL,8 year int NOT NULL,9 type text NOT NULL CHECK (type IN ('sedan','suv','truck','van','luxury')),10 daily_rate_cents int NOT NULL,11 image_url text,12 plate_number text UNIQUE NOT NULL,13 mileage int DEFAULT 0,14 status text DEFAULT 'available' CHECK (status IN ('available','rented','maintenance')),15 features jsonb DEFAULT '[]',16 created_at timestamptz DEFAULT now()17);1819CREATE TABLE bookings (20 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),21 vehicle_id uuid REFERENCES vehicles(id) ON DELETE CASCADE,22 customer_id uuid NOT NULL,23 start_date date NOT NULL,24 end_date date NOT NULL,25 total_cents int NOT NULL,26 status text DEFAULT 'pending' CHECK (status IN ('pending','confirmed','active','completed','cancelled')),27 stripe_payment_intent_id text,28 created_at timestamptz DEFAULT now(),29 CONSTRAINT no_overlap EXCLUDE USING gist (30 vehicle_id WITH =,31 daterange(start_date, end_date) WITH &&32 ) WHERE (status NOT IN ('cancelled'))33);3435CREATE TABLE reviews (36 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),37 booking_id uuid REFERENCES bookings(id) UNIQUE,38 rating int CHECK (rating >= 1 AND rating <= 5),39 comment text,40 created_at timestamptz DEFAULT now()41);Pro tip: The EXCLUDE constraint with WHERE (status NOT IN ('cancelled')) means cancelled bookings are ignored when checking for overlaps. This lets customers cancel and rebook the same dates without constraint violations.
Expected result: Three tables created with a gist EXCLUDE constraint that prevents any two non-cancelled bookings for the same vehicle from having overlapping date ranges.
Create the availability RPC function and fleet browsing page
Create a Supabase RPC function that returns vehicles with no overlapping confirmed bookings for a given date range. Then build the fleet browsing page with vehicle cards and date filtering.
1-- Supabase RPC function for availability checking2CREATE OR REPLACE FUNCTION available_vehicles(p_start date, p_end date)3RETURNS SETOF vehicles AS $$4 SELECT v.*5 FROM vehicles v6 WHERE v.status = 'available'7 AND NOT EXISTS (8 SELECT 1 FROM bookings b9 WHERE b.vehicle_id = v.id10 AND b.status NOT IN ('cancelled')11 AND daterange(b.start_date, b.end_date) && daterange(p_start, p_end)12 )13 ORDER BY v.daily_rate_cents ASC;14$$ LANGUAGE sql STABLE;Pro tip: Use V0's prompt queuing to queue three prompts: the fleet page, the vehicle detail page, and the admin dashboard. They will generate in sequence while you review each one.
Expected result: The RPC function returns only vehicles with no booking conflicts for the selected date range. The fleet page shows vehicle Cards with type Badge, daily rate, and features.
Build the booking flow with Stripe Payment Intent
Create the vehicle detail page with a booking form that collects dates, calculates total cost, creates a Stripe Payment Intent for the deposit, and inserts the booking record.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@/lib/supabase/server'3import Stripe from 'stripe'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)67export async function POST(req: NextRequest) {8 const supabase = await createClient()9 const { vehicleId, startDate, endDate } = await req.json()1011 const { data: vehicle } = await supabase12 .from('vehicles')13 .select('daily_rate_cents, make, model')14 .eq('id', vehicleId)15 .single()1617 if (!vehicle) {18 return NextResponse.json({ error: 'Vehicle not found' }, { status: 404 })19 }2021 const days = Math.ceil(22 (new Date(endDate).getTime() - new Date(startDate).getTime()) / 8640000023 )24 const totalCents = vehicle.daily_rate_cents * days2526 const paymentIntent = await stripe.paymentIntents.create({27 amount: totalCents,28 currency: 'usd',29 metadata: { vehicleId, startDate, endDate },30 })3132 const { data: booking, error } = await supabase33 .from('bookings')34 .insert({35 vehicle_id: vehicleId,36 customer_id: (await supabase.auth.getUser()).data.user?.id,37 start_date: startDate,38 end_date: endDate,39 total_cents: totalCents,40 stripe_payment_intent_id: paymentIntent.id,41 })42 .select()43 .single()4445 if (error?.code === '23P01') {46 return NextResponse.json(47 { error: 'Vehicle is not available for these dates' },48 { status: 409 }49 )50 }5152 return NextResponse.json({53 bookingId: booking?.id,54 clientSecret: paymentIntent.client_secret,55 })56}Pro tip: Error code 23P01 is PostgreSQL's exclusion_violation. Catching it lets you return a friendly 'dates unavailable' message when two users try to book the same vehicle simultaneously.
Expected result: Submitting a booking creates a Stripe Payment Intent and inserts the booking. If dates overlap with an existing booking, the EXCLUDE constraint rejects it with a 409 conflict response.
Build the admin fleet management dashboard
Create an admin page showing all vehicles in a Table with status management, booking overview, and the ability to mark vehicles for maintenance.
1// Paste this prompt into V0's AI chat:2// Build an admin fleet management page at app/admin/fleet/page.tsx with:3// 1. Server Component fetching all vehicles with their active booking count4// 2. shadcn/ui Table with columns: Vehicle (make + model + year), Plate, Type Badge, Daily Rate, Mileage, Status Badge, Actions5// 3. Status Badge: 'available' = default, 'rented' = secondary, 'maintenance' = destructive6// 4. Action buttons: Edit (Dialog with Form), Toggle Maintenance, View Bookings7// 5. Summary Cards at top: total fleet size, currently rented, in maintenance, revenue this month8// 6. Server Actions for updateVehicleStatus and updateVehicleDetails9// 7. Filter by vehicle type using Select dropdown10// Use Supabase for all data fetching and mutations.Expected result: An admin dashboard Table showing the entire fleet with status Badges, action buttons, and summary Cards for fleet metrics.
Build the customer bookings page with status tracking
Create a page where customers can view their booking history, see current reservations, and cancel pending bookings.
1// Paste this prompt into V0's AI chat:2// Build a customer bookings page at app/bookings/page.tsx with:3// 1. Server Component fetching all bookings for the current user with vehicle details4// 2. shadcn/ui Card for each booking showing vehicle image, make/model, dates, total price5// 3. Badge for status: pending = outline, confirmed = default, active = secondary, completed = default, cancelled = destructive6// 4. Cancel button (AlertDialog confirmation) for pending bookings using Server Action7// 5. Review button (Dialog with star rating + textarea) for completed bookings8// 6. Tabs for Active, Upcoming, and Past bookings9// 7. Empty state with illustration when no bookings exist10// Cancel action should update booking status and the EXCLUDE constraint will free up those dates.Expected result: A bookings page with tabbed views for active, upcoming, and past reservations. Customers can cancel pending bookings and leave reviews on completed ones.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@/lib/supabase/server'3import Stripe from 'stripe'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)67export async function POST(req: NextRequest) {8 const supabase = await createClient()9 const { vehicleId, startDate, endDate } = await req.json()1011 const { data: vehicle } = await supabase12 .from('vehicles')13 .select('daily_rate_cents, make, model')14 .eq('id', vehicleId)15 .single()1617 if (!vehicle) {18 return NextResponse.json({ error: 'Vehicle not found' }, { status: 404 })19 }2021 const days = Math.ceil(22 (new Date(endDate).getTime() - new Date(startDate).getTime()) / 8640000023 )24 const totalCents = vehicle.daily_rate_cents * days2526 const paymentIntent = await stripe.paymentIntents.create({27 amount: totalCents,28 currency: 'usd',29 metadata: { vehicleId, startDate, endDate },30 })3132 const { data: booking, error } = await supabase33 .from('bookings')34 .insert({35 vehicle_id: vehicleId,36 customer_id: (await supabase.auth.getUser()).data.user?.id,37 start_date: startDate,38 end_date: endDate,39 total_cents: totalCents,40 stripe_payment_intent_id: paymentIntent.id,41 })42 .select()43 .single()4445 if (error?.code === '23P01') {46 return NextResponse.json(47 { error: 'Vehicle is not available for these dates' },48 { status: 409 }49 )50 }5152 return NextResponse.json({53 bookingId: booking?.id,54 clientSecret: paymentIntent.client_secret,55 })56}Customization ideas
Add vehicle image gallery
Store multiple images per vehicle in Supabase Storage and display them in a carousel on the vehicle detail page using shadcn/ui's Carousel component.
Add GPS tracking integration
Integrate with a GPS tracking API to show real-time vehicle locations on a map for the admin dashboard, helping with fleet utilization monitoring.
Add dynamic pricing
Implement surge pricing based on demand. Calculate a multiplier from the ratio of booked-to-available vehicles for the selected dates and adjust the daily rate accordingly.
Add damage reporting
Create a check-in/check-out flow where customers photograph the vehicle before and after the rental. Store images in Supabase Storage with timestamps for dispute resolution.
Add loyalty program
Track rental days per customer and offer discount tiers (5% after 10 days, 10% after 30 days). Apply discounts automatically during booking.
Common pitfalls
Pitfall: Checking availability only in application code without database constraints
How to avoid: Use PostgreSQL's EXCLUDE constraint with gist index. It prevents overlapping date ranges at the database level, making double-bookings physically impossible regardless of concurrent requests.
Pitfall: Not filtering cancelled bookings from the EXCLUDE constraint
How to avoid: Add WHERE (status NOT IN ('cancelled')) to the EXCLUDE constraint so cancelled bookings are ignored during overlap checks.
Pitfall: Calculating rental days with simple date subtraction
How to avoid: Use Math.ceil() on the millisecond difference divided by 86400000, and enforce a minimum of 1 day in both the UI validation and the API.
Pitfall: Exposing STRIPE_SECRET_KEY with NEXT_PUBLIC_ prefix
How to avoid: Set STRIPE_SECRET_KEY in V0's Vars tab without any prefix. Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY should be exposed to the client.
Best practices
- Enable the btree_gist extension before creating EXCLUDE constraints — it is required for combining equality and range operators in exclusion
- Use Supabase RPC functions for complex availability queries to keep logic in SQL where it performs best
- Create the Stripe Payment Intent server-side and only send the clientSecret to the frontend for confirmation
- Catch PostgreSQL error code 23P01 (exclusion_violation) specifically to return user-friendly availability messages
- Use V0's Connect panel to add both Supabase and Stripe via Vercel Marketplace for automatic env var configuration
- Use Design Mode (Option+D) to visually polish vehicle Cards and the booking form at zero credit cost
- Add RLS policies so customers can only see and cancel their own bookings while admins can manage all
AI prompts to try
Copy these prompts to build this project faster.
I'm building a vehicle rental platform with Next.js App Router and Supabase. I need: 1) A PostgreSQL EXCLUDE constraint using gist to prevent double-bookings on overlapping date ranges, 2) An RPC function to query available vehicles for a date range, 3) Stripe Payment Intent creation for booking deposits, 4) Handling the 23P01 exclusion_violation error gracefully. Help me design the schema and booking flow.
Create a booking API route at app/api/bookings/route.ts that: 1) Receives vehicleId, startDate, endDate, 2) Fetches vehicle daily_rate_cents from Supabase, 3) Calculates total from date difference, 4) Creates a Stripe Payment Intent, 5) Inserts the booking with stripe_payment_intent_id, 6) Catches PostgreSQL error 23P01 (exclusion_violation) and returns 409 with 'dates unavailable', 7) Returns clientSecret for frontend payment confirmation.
Frequently asked questions
How does the EXCLUDE constraint prevent double-bookings?
The EXCLUDE constraint with gist index checks every INSERT against existing rows. If a new booking's vehicle_id matches an existing row AND the date ranges overlap (using the && operator), PostgreSQL rejects the insert with error 23P01. This happens at the database level, so even concurrent requests cannot create conflicts.
What happens if two users try to book the same dates simultaneously?
PostgreSQL's EXCLUDE constraint handles this atomically. The first INSERT succeeds. The second triggers error 23P01 (exclusion_violation), which the API catches and returns a 409 'dates unavailable' response. No application-level locking is needed.
Do I need the btree_gist extension?
Yes. The btree_gist extension is required for EXCLUDE constraints that combine equality operators (= for vehicle_id) with range operators (&& for daterange). Run CREATE EXTENSION IF NOT EXISTS btree_gist before creating the bookings table.
What V0 plan do I need?
V0 Premium ($20/month) is recommended because it gives enough credits for building the fleet page, booking flow, and admin dashboard. The Supabase and Stripe integrations are configured through V0's Connect panel.
How do I handle payment refunds for cancelled bookings?
When a customer cancels, use the stored stripe_payment_intent_id to create a refund via stripe.refunds.create({ payment_intent: booking.stripe_payment_intent_id }). The booking status changes to 'cancelled' and the EXCLUDE constraint immediately frees those dates for rebooking.
How do I deploy this?
Click Share and then Publish in V0. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab (no NEXT_PUBLIC_ prefix). The Supabase connection is auto-configured via the Connect panel. Register webhook endpoints in the Stripe dashboard using the production Vercel URL.
Can RapidDev help build a custom vehicle rental platform?
Yes. RapidDev has built 600+ apps including fleet management platforms with real-time availability, multi-location support, and payment processing. Book a free consultation to discuss your rental platform requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation