Build an Uber-style ride-hailing platform with V0 using Next.js and Supabase that matches riders with nearby drivers using PostGIS spatial queries, tracks rides in real time via Supabase Realtime, calculates fares, and processes payments with Stripe — all in about 2-4 hours.
What you're building
Ride-hailing is one of the most technically demanding real-time applications you can build. It combines geospatial queries, live location tracking, real-time state synchronization, and payment processing — all under strict timing constraints where a few seconds of delay ruin the user experience.
V0 generates the rider and driver interfaces, ride request flow, and map components from prompts. The key architectural challenge is nearest-driver matching under concurrent load. A Supabase RPC function uses PostGIS ST_DWithin to find drivers within 5km, ordered by distance, and atomically sets the matched driver's status to 'busy' inside a transaction — preventing two riders from being matched to the same driver.
The architecture uses Next.js App Router with separate rider and driver pages, API routes for ride operations that require PostGIS queries and Stripe charges, Supabase Realtime for live ride status and driver location updates, and a Stripe webhook for payment confirmation.
Final result
A ride-hailing platform with rider and driver interfaces, real-time GPS tracking on an interactive map, PostGIS-powered nearest-driver matching, distance-based fare calculation, Stripe payment processing, and a mutual rating system.
Tech stack
Prerequisites
- A V0 account (Premium recommended for the complexity of this build)
- A Supabase project with PostGIS extension enabled (free tier works — connect via V0's Connect panel)
- A Stripe account with test mode keys
- A Google Maps API key with Maps JavaScript API enabled
Build steps
Set up the project, PostGIS, and ride-hailing schema
Open V0 and create a new project. Use the Connect panel to add Supabase. Enable the PostGIS extension, then create the schema for riders, drivers with spatial columns, rides, and ratings.
1// Paste this prompt into V0's AI chat:2// Build a ride-hailing platform. First, enable PostGIS:3// CREATE EXTENSION IF NOT EXISTS postgis;4//5// Create a Supabase schema with:6// 1. riders: id (uuid PK), user_id (uuid FK UNIQUE), first_name (text), last_name (text), phone (text), payment_method_id (text), rating (numeric DEFAULT 5.0), created_at (timestamptz)7// 2. drivers: id (uuid PK), user_id (uuid FK UNIQUE), first_name (text), last_name (text), phone (text), vehicle_make (text), vehicle_model (text), vehicle_plate (text), license_number (text), status (text CHECK offline/available/busy), current_location (geography(Point, 4326)), rating (numeric DEFAULT 5.0), created_at (timestamptz)8// Add a spatial index: CREATE INDEX idx_drivers_location ON drivers USING GIST (current_location)9// 3. rides: id (uuid PK), rider_id (uuid FK), driver_id (uuid FK), pickup_location (geography(Point, 4326)), dropoff_location (geography(Point, 4326)), pickup_address (text), dropoff_address (text), status (text CHECK requested/matching/accepted/arriving/in_progress/completed/cancelled), fare_estimate (integer), fare_final (integer), distance_meters (integer), duration_seconds (integer), requested_at (timestamptz), completed_at (timestamptz)10// 4. ride_ratings: id (uuid PK), ride_id (uuid FK), from_user_id (uuid FK), to_user_id (uuid FK), rating (integer CHECK 1-5), comment (text), created_at (timestamptz)11// Enable Supabase Realtime on rides and drivers tables.12// RLS: riders see own rides, drivers see assigned rides, driver location updates only by self.13// Generate SQL migration and TypeScript types.Pro tip: Enable PostGIS BEFORE creating the schema — the geography(Point, 4326) column type requires the extension to exist. Run CREATE EXTENSION postgis in the Supabase SQL editor if V0 doesn't do it automatically.
Expected result: Supabase is connected with PostGIS enabled, all four tables created with a spatial index on drivers.current_location, and Realtime enabled on rides and drivers tables.
Build the rider interface with ride request flow
Create the rider page with a map for selecting pickup and dropoff locations, a ride request button, and live tracking of the assigned driver's location once a ride is accepted.
1// Paste this prompt into V0's AI chat:2// Build a rider page at app/ride/page.tsx.3// Requirements:4// - Interactive map using @vis.gl/react-google-maps showing the rider's current location5// - Tap or search to set pickup and dropoff locations (markers on map)6// - Card at bottom showing pickup/dropoff addresses, estimated fare, estimated time7// - "Request Ride" Button that calls POST /api/rides/request8// - After requesting: Skeleton loading state while matching9// - Once matched: show driver Card with Avatar, name, vehicle info, rating Badge10// - Live driver location on map via Supabase Realtime subscription on drivers table11// - Ride status Badge (matching → accepted → arriving → in_progress → completed)12// - "Cancel Ride" Button with AlertDialog confirmation (only before in_progress)13// - On completion: rating Dialog with 1-5 stars and optional comment14// - 'use client' for the map and Realtime listener15// - Use shadcn/ui Card, Badge, Avatar, Button, AlertDialog, Skeleton, Toast, SheetExpected result: A rider page with an interactive map, pickup/dropoff selection, ride request flow, live driver tracking, and a post-ride rating dialog.
Create the driver matching API with PostGIS spatial queries
Build the API route that finds the nearest available drivers using PostGIS, creates a ride record, and atomically marks the matched driver as busy to prevent race conditions.
1'use server'23import { createClient } from '@/lib/supabase/server'45export async function requestRide(formData: FormData) {6 const supabase = await createClient()7 const { data: { user } } = await supabase.auth.getUser()8 if (!user) throw new Error('Must be logged in')910 const pickupLat = parseFloat(formData.get('pickup_lat') as string)11 const pickupLng = parseFloat(formData.get('pickup_lng') as string)12 const dropoffLat = parseFloat(formData.get('dropoff_lat') as string)13 const dropoffLng = parseFloat(formData.get('dropoff_lng') as string)1415 // Find nearest available driver within 5km and atomically set to busy16 const { data: driver, error: matchError } = await supabase.rpc(17 'match_nearest_driver',18 {19 p_pickup_lng: pickupLng,20 p_pickup_lat: pickupLat,21 p_radius_meters: 5000,22 }23 )2425 if (matchError || !driver) {26 throw new Error('No drivers available nearby')27 }2829 const { data: ride } = await supabase30 .from('rides')31 .insert({32 rider_id: user.id,33 driver_id: driver.id,34 pickup_location: `POINT(${pickupLng} ${pickupLat})`,35 dropoff_location: `POINT(${dropoffLng} ${dropoffLat})`,36 pickup_address: formData.get('pickup_address') as string,37 dropoff_address: formData.get('dropoff_address') as string,38 fare_estimate: parseInt(formData.get('fare_estimate') as string),39 status: 'accepted',40 requested_at: new Date().toISOString(),41 })42 .select()43 .single()4445 return ride46}Pro tip: The match_nearest_driver RPC function must run inside a transaction: SELECT ... FROM drivers WHERE status = 'available' AND ST_DWithin(current_location, ST_MakePoint($lng, $lat)::geography, $radius) ORDER BY ST_Distance(current_location, ST_MakePoint($lng, $lat)::geography) LIMIT 1 FOR UPDATE — then UPDATE drivers SET status = 'busy'. The FOR UPDATE lock prevents two concurrent requests from matching the same driver.
Expected result: A Server Action that finds the nearest available driver using PostGIS, creates a ride, and atomically locks the driver to prevent double-matching.
Build the driver interface and ride completion with Stripe
Create the driver page with incoming ride requests, accept/reject controls, navigation to pickup, ride completion, and Stripe payment processing on ride end.
1// Paste this prompt into V0's AI chat:2// Build a driver page at app/driver/page.tsx and a ride completion API.3// Driver page requirements:4// - Map showing current location and active ride route5// - When offline: Toggle Switch to go online (sets status to 'available', starts location broadcasting)6// - When available: waiting Card with Skeleton7// - Incoming ride: Card showing rider Avatar, pickup address, estimated fare, distance8// - Accept Button and Reject Button with 15-second auto-reject countdown9// - During ride: navigation view with pickup/dropoff markers, status progression Buttons10// - "Arrived at Pickup" → "Start Ride" → "Complete Ride"11// - Supabase Realtime subscription on rides table filtered by driver_id12// - Location update: broadcast driver position every 5 seconds via /api/rides/[id]/update-location13//14// Ride completion API at app/api/rides/[id]/complete/route.ts:15// - Calculate final fare from distance_meters and duration_seconds16// - Create Stripe PaymentIntent with rider's payment_method_id17// - Update ride with fare_final and status 'completed'18// - Return ride summary19//20// Stripe webhook at app/api/webhooks/stripe/route.ts:21// - Verify signature using request.text() for raw body22// - Handle payment_intent.succeeded — confirm ride payment23//24// Use shadcn/ui Card, Badge, Avatar, Button, Switch, AlertDialog, Toast, SkeletonExpected result: A driver page with online/offline toggle, ride accept/reject, navigation controls, and an API route that calculates the final fare and charges via Stripe on ride completion.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'3import Stripe from 'stripe'45const supabase = createClient(6 process.env.SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)9const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)1011const BASE_FARE = 250 // $2.5012const PER_KM = 150 // $1.50 per km13const PER_MIN = 25 // $0.25 per minute1415export async function POST(16 req: NextRequest,17 { params }: { params: Promise<{ id: string }> }18) {19 const { id } = await params2021 const { data: ride } = await supabase22 .from('rides')23 .select('*, riders!inner(payment_method_id)')24 .eq('id', id)25 .eq('status', 'in_progress')26 .single()2728 if (!ride) {29 return NextResponse.json({ error: 'Ride not found' }, { status: 404 })30 }3132 const distanceKm = (ride.distance_meters || 0) / 100033 const durationMin = (ride.duration_seconds || 0) / 6034 const fareFinal = Math.round(BASE_FARE + distanceKm * PER_KM + durationMin * PER_MIN)3536 await stripe.paymentIntents.create({37 amount: fareFinal,38 currency: 'usd',39 payment_method: ride.riders.payment_method_id,40 confirm: true,41 automatic_payment_methods: {42 enabled: true,43 allow_redirects: 'never',44 },45 metadata: { ride_id: id },46 })4748 const { data: completed } = await supabase49 .from('rides')50 .update({51 status: 'completed',52 fare_final: fareFinal,53 completed_at: new Date().toISOString(),54 })55 .eq('id', id)56 .select()57 .single()5859 return NextResponse.json(completed)60}Customization ideas
Add surge pricing
Calculate a surge multiplier based on the ratio of ride requests to available drivers in a geographic area, and apply it to fare estimates during high-demand periods.
Build ride sharing / carpooling
Match multiple riders heading in similar directions to the same driver, splitting fares based on individual distance traveled.
Add driver earnings dashboard
Build a dashboard showing daily/weekly earnings, trip count, average rating, and payout history using Recharts visualizations.
Implement ride scheduling
Let riders schedule rides in advance. Use a Supabase cron job to begin driver matching 10 minutes before the scheduled pickup time.
Common pitfalls
Pitfall: Not locking the driver row during matching, causing two riders to match the same driver
How to avoid: Use a Supabase RPC function with SELECT ... FOR UPDATE to lock the driver row, then UPDATE status to 'busy' inside the same transaction. This guarantees atomic matching.
Pitfall: Storing coordinates as separate latitude and longitude columns instead of PostGIS geography
How to avoid: Use the geography(Point, 4326) column type with a GIST index. PostGIS ST_DWithin and ST_Distance use the spatial index and calculate distances accurately on the Earth's surface.
Pitfall: Broadcasting driver location updates through the client instead of through an API route
How to avoid: Send location updates from the driver client to an API route (app/api/rides/[id]/update-location/route.ts) that validates and writes to Supabase. The Realtime subscription on the riders table picks up the change automatically.
Best practices
- Enable PostGIS extension before creating schema — geography column types require it
- Use ST_DWithin with a GIST spatial index for nearest-driver queries instead of calculating distances in application code
- Lock driver rows with SELECT ... FOR UPDATE inside an RPC function to prevent concurrent matching race conditions
- Use Supabase Realtime subscriptions on rides and drivers tables for live status and location updates
- Store STRIPE_SECRET_KEY and SUPABASE_SERVICE_ROLE_KEY without NEXT_PUBLIC_ prefix in V0's Vars tab — they must stay server-only
- Use request.text() (not request.json()) in the Stripe webhook handler to preserve the raw body for signature verification
AI prompts to try
Copy these prompts to build this project faster.
I'm building a ride-hailing platform with Next.js and Supabase with PostGIS. Write a PostgreSQL function called match_nearest_driver that takes pickup longitude, latitude, and radius in meters as parameters. It should find the nearest available driver within the radius using ST_DWithin, lock the row with FOR UPDATE, set the driver's status to 'busy', and return the driver record. Use a transaction to prevent race conditions. Include the CREATE FUNCTION statement.
Create a ride status tracker component. Accept a ride object with status, driver info, and locations. Show a vertical stepper with stages: Requested → Matched → Driver Arriving → In Progress → Completed. Highlight the current stage with a colored Badge. Show driver Avatar, vehicle info, and ETA for active stages. Use shadcn/ui Card, Badge, Avatar, and Separator. Mark as 'use client' for Realtime updates.
Frequently asked questions
What V0 plan do I need for a ride-hailing platform?
V0 Premium ($20/month) is recommended because this build involves multiple complex pages (rider map, driver interface, APIs) that benefit from prompt queuing to generate sequentially.
How does the driver matching prevent two riders from getting the same driver?
A Supabase RPC function uses SELECT ... FOR UPDATE to lock the driver's database row during matching. Inside the same transaction, it sets the driver's status to 'busy'. This database-level lock guarantees that only one ride request can claim a driver, even under concurrent load.
Do I need a paid Google Maps API key?
Google Maps provides $200/month in free credits, which covers approximately 28,000 map loads. For development and small-scale testing, this is more than enough. Set NEXT_PUBLIC_GOOGLE_MAPS_KEY in V0's Vars tab.
How does real-time tracking work?
The driver client sends location updates every 5 seconds to an API route that writes to the drivers table. Supabase Realtime broadcasts the change to all subscribers. The rider client listens for updates on the matched driver's row and moves the marker on the map.
How do I deploy the ride-hailing platform?
Click Share then Publish to Production in V0. After the first deploy, register the Stripe webhook URL (https://yourdomain.vercel.app/api/webhooks/stripe) in the Stripe Dashboard. Set all env vars in V0's Vars tab: NEXT_PUBLIC_GOOGLE_MAPS_KEY (client), STRIPE_SECRET_KEY and SUPABASE_SERVICE_ROLE_KEY (server-only, no prefix).
Can RapidDev help build a custom ride-hailing platform?
Yes. RapidDev has built 600+ apps including real-time geospatial platforms with driver matching, surge pricing, and multi-region deployment. Book a free consultation to discuss your requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation