# How to Build Ride hailing platform with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
// Paste this prompt into V0's AI chat:
// Build a ride-hailing platform. First, enable PostGIS:
// CREATE EXTENSION IF NOT EXISTS postgis;
//
// Create a Supabase schema with:
// 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)
// 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)
//    Add a spatial index: CREATE INDEX idx_drivers_location ON drivers USING GIST (current_location)
// 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)
// 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)
// Enable Supabase Realtime on rides and drivers tables.
// RLS: riders see own rides, drivers see assigned rides, driver location updates only by self.
// 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.

### 2. 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.

```
// Paste this prompt into V0's AI chat:
// Build a rider page at app/ride/page.tsx.
// Requirements:
// - Interactive map using @vis.gl/react-google-maps showing the rider's current location
// - Tap or search to set pickup and dropoff locations (markers on map)
// - Card at bottom showing pickup/dropoff addresses, estimated fare, estimated time
// - "Request Ride" Button that calls POST /api/rides/request
// - After requesting: Skeleton loading state while matching
// - Once matched: show driver Card with Avatar, name, vehicle info, rating Badge
// - Live driver location on map via Supabase Realtime subscription on drivers table
// - Ride status Badge (matching → accepted → arriving → in_progress → completed)
// - "Cancel Ride" Button with AlertDialog confirmation (only before in_progress)
// - On completion: rating Dialog with 1-5 stars and optional comment
// - 'use client' for the map and Realtime listener
// - Use shadcn/ui Card, Badge, Avatar, Button, AlertDialog, Skeleton, Toast, Sheet
```

**Expected result:** A rider page with an interactive map, pickup/dropoff selection, ride request flow, live driver tracking, and a post-ride rating dialog.

### 3. 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.

```
'use server'

import { createClient } from '@/lib/supabase/server'

export async function requestRide(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Must be logged in')

  const pickupLat = parseFloat(formData.get('pickup_lat') as string)
  const pickupLng = parseFloat(formData.get('pickup_lng') as string)
  const dropoffLat = parseFloat(formData.get('dropoff_lat') as string)
  const dropoffLng = parseFloat(formData.get('dropoff_lng') as string)

  // Find nearest available driver within 5km and atomically set to busy
  const { data: driver, error: matchError } = await supabase.rpc(
    'match_nearest_driver',
    {
      p_pickup_lng: pickupLng,
      p_pickup_lat: pickupLat,
      p_radius_meters: 5000,
    }
  )

  if (matchError || !driver) {
    throw new Error('No drivers available nearby')
  }

  const { data: ride } = await supabase
    .from('rides')
    .insert({
      rider_id: user.id,
      driver_id: driver.id,
      pickup_location: `POINT(${pickupLng} ${pickupLat})`,
      dropoff_location: `POINT(${dropoffLng} ${dropoffLat})`,
      pickup_address: formData.get('pickup_address') as string,
      dropoff_address: formData.get('dropoff_address') as string,
      fare_estimate: parseInt(formData.get('fare_estimate') as string),
      status: 'accepted',
      requested_at: new Date().toISOString(),
    })
    .select()
    .single()

  return ride
}
```

> 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.

### 4. 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.

```
// Paste this prompt into V0's AI chat:
// Build a driver page at app/driver/page.tsx and a ride completion API.
// Driver page requirements:
// - Map showing current location and active ride route
// - When offline: Toggle Switch to go online (sets status to 'available', starts location broadcasting)
// - When available: waiting Card with Skeleton
// - Incoming ride: Card showing rider Avatar, pickup address, estimated fare, distance
//   - Accept Button and Reject Button with 15-second auto-reject countdown
// - During ride: navigation view with pickup/dropoff markers, status progression Buttons
//   - "Arrived at Pickup" → "Start Ride" → "Complete Ride"
// - Supabase Realtime subscription on rides table filtered by driver_id
// - Location update: broadcast driver position every 5 seconds via /api/rides/[id]/update-location
//
// Ride completion API at app/api/rides/[id]/complete/route.ts:
// - Calculate final fare from distance_meters and duration_seconds
// - Create Stripe PaymentIntent with rider's payment_method_id
// - Update ride with fare_final and status 'completed'
// - Return ride summary
//
// Stripe webhook at app/api/webhooks/stripe/route.ts:
// - Verify signature using request.text() for raw body
// - Handle payment_intent.succeeded — confirm ride payment
//
// Use shadcn/ui Card, Badge, Avatar, Button, Switch, AlertDialog, Toast, Skeleton
```

**Expected 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 example

File: `app/api/rides/[id]/complete/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import Stripe from 'stripe'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

const BASE_FARE = 250 // $2.50
const PER_KM = 150 // $1.50 per km
const PER_MIN = 25 // $0.25 per minute

export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  const { data: ride } = await supabase
    .from('rides')
    .select('*, riders!inner(payment_method_id)')
    .eq('id', id)
    .eq('status', 'in_progress')
    .single()

  if (!ride) {
    return NextResponse.json({ error: 'Ride not found' }, { status: 404 })
  }

  const distanceKm = (ride.distance_meters || 0) / 1000
  const durationMin = (ride.duration_seconds || 0) / 60
  const fareFinal = Math.round(BASE_FARE + distanceKm * PER_KM + durationMin * PER_MIN)

  await stripe.paymentIntents.create({
    amount: fareFinal,
    currency: 'usd',
    payment_method: ride.riders.payment_method_id,
    confirm: true,
    automatic_payment_methods: {
      enabled: true,
      allow_redirects: 'never',
    },
    metadata: { ride_id: id },
  })

  const { data: completed } = await supabase
    .from('rides')
    .update({
      status: 'completed',
      fare_final: fareFinal,
      completed_at: new Date().toISOString(),
    })
    .eq('id', id)
    .select()
    .single()

  return NextResponse.json(completed)
}
```

## Common mistakes

- **Not locking the driver row during matching, causing two riders to match the same driver** — Without a database-level lock, two concurrent ride requests can read the same driver as 'available' and both assign them, leaving one rider stranded. Fix: 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.
- **Storing coordinates as separate latitude and longitude columns instead of PostGIS geography** — Separate float columns cannot use spatial indexes. Distance calculations require expensive trigonometric functions on every row instead of an index scan. Fix: 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.
- **Broadcasting driver location updates through the client instead of through an API route** — Client-side location broadcasts are unreliable (tab backgrounds throttle timers) and expose the driver's update mechanism to manipulation. Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/ride-hailing-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/ride-hailing-platform
