# How to Build a Ride Hailing Platform with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a ride-hailing platform in Lovable with real-time driver location tracking via Supabase Realtime Broadcast, driver matching using PostGIS, a map component for live location display, ride status updates, fare calculation, and mutual driver-rider ratings — all backed by Supabase Edge Functions with no separate server.

## Before you start

- Lovable Pro account with Edge Functions access
- Supabase project with PostGIS extension enabled (Database → Extensions → postgis)
- Supabase URL and service role key in Cloud tab → Secrets
- Supabase Auth with two test accounts (one for driver role, one for rider)
- Basic understanding of latitude/longitude coordinates for testing location inputs

## Step-by-step guide

### 1. Enable PostGIS and create the ride-hailing schema

Enable the PostGIS extension in Supabase and create all tables for the platform. The key tables are drivers, rides, and driver_locations. PostGIS geography columns enable distance-based queries.

```
Set up a ride-hailing platform schema in Supabase with PostGIS.

First, enable PostGIS: CREATE EXTENSION IF NOT EXISTS postgis;

Tables:

1. driver_profiles: id (references auth.users), vehicle_make, vehicle_model, vehicle_plate, license_number, is_online (bool default false), average_rating (numeric default 5.0), total_rides (int default 0), created_at

2. driver_locations: id, driver_id (references driver_profiles, UNIQUE), location (geography(Point, 4326)), heading (int), speed_kmh (int), updated_at

3. rides: id, rider_id (references auth.users), driver_id (references driver_profiles, nullable), pickup_address (text), pickup_location (geography(Point, 4326)), destination_address (text), destination_location (geography(Point, 4326)), status (text: searching|driver_assigned|en_route|arrived|in_progress|completed|cancelled), fare_cents (int), surge_multiplier (numeric default 1.0), distance_km (numeric), duration_minutes (int), rider_rating (int nullable), driver_rating (int nullable), requested_at, accepted_at, started_at, completed_at

4. surge_config: id, area_name (text), multiplier (numeric default 1.0), updated_at

RLS:
- driver_profiles: public SELECT, own UPDATE
- driver_locations: public SELECT (riders need to see driver positions), driver own INSERT/UPDATE
- rides: rider can SELECT and INSERT their own rides; driver can SELECT rides assigned to them or searching rides; driver can UPDATE status on their assigned ride

Index: CREATE INDEX ON driver_locations USING GIST(location);
Index: CREATE INDEX ON rides(status, requested_at DESC);
```

> Pro tip: Ask Lovable to also create a Postgres function find_nearby_drivers(pickup_lng float, pickup_lat float, radius_meters int) that returns driver_id, distance_meters, vehicle details for online drivers within the radius. This query uses ST_DWithin and ST_Distance — much faster than doing this in application code.

**Expected result:** PostGIS extension is enabled. All tables created with geography columns and a GIST index. find_nearby_drivers RPC function works in Supabase Table Editor. TypeScript types generated.

### 2. Build the driver location broadcast system

Set up the Realtime Broadcast channel for live location streaming. The driver app sends location pings every 3 seconds via Broadcast. The rider map subscribes and updates the driver marker without any database writes per ping.

```
// src/hooks/useDriverLocationBroadcast.ts
// Runs in the DRIVER's app — sends location every 3 seconds
import { useEffect, useRef } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

export function useDriverLocationBroadcast(rideId: string | null, isOnline: boolean) {
  const { user } = useAuth()
  const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)
  const intervalRef = useRef<ReturnType<typeof setInterval>>()

  useEffect(() => {
    if (!rideId || !isOnline || !user?.id) return

    const channel = supabase.channel(`ride:${rideId}:location`)
    channelRef.current = channel
    channel.subscribe()

    intervalRef.current = setInterval(() => {
      if (!navigator.geolocation) return
      navigator.geolocation.getCurrentPosition((pos) => {
        channel.send({
          type: 'broadcast',
          event: 'location',
          payload: {
            driver_id: user.id,
            lat: pos.coords.latitude,
            lng: pos.coords.longitude,
            heading: pos.coords.heading ?? 0,
            timestamp: Date.now(),
          },
        })
      })
    }, 3000)

    return () => {
      clearInterval(intervalRef.current)
      supabase.removeChannel(channel)
    }
  }, [rideId, isOnline, user?.id])
}

// src/hooks/useRiderLocationSubscription.ts
// Runs in the RIDER's app — receives driver location
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'

type DriverLocation = { driver_id: string; lat: number; lng: number; heading: number }

export function useRiderLocationSubscription(rideId: string | null) {
  const [driverLocation, setDriverLocation] = useState<DriverLocation | null>(null)

  useEffect(() => {
    if (!rideId) return
    const channel = supabase
      .channel(`ride:${rideId}:location`)
      .on('broadcast', { event: 'location' }, (payload) => {
        setDriverLocation(payload.payload as DriverLocation)
      })
      .subscribe()
    return () => { supabase.removeChannel(channel) }
  }, [rideId])

  return { driverLocation }
}
```

> Pro tip: Also upsert the driver's location to driver_locations every 30 seconds (not every 3 seconds) for persistence. The Broadcast stream gives live tracking, while the upsert gives a last-known location that survives connection drops and powers the initial map render before Broadcast events start arriving.

**Expected result:** The driver app broadcasts location updates. The rider app receives them and can pass the coordinates to the map component to update the driver marker smoothly.

### 3. Build the fare calculation Edge Function and ride request flow

Create the fare calculation Edge Function and the rider-facing ride request form. The form collects pickup and destination, gets a fare estimate, and on confirm creates the ride row and triggers driver matching.

```
Create two Edge Functions:

1. supabase/functions/calculate-fare/index.ts
- Accept POST: { pickup_lat, pickup_lng, dest_lat, dest_lng }
- Calculate straight-line distance using ST_Distance via Supabase query: SELECT ST_Distance(ST_Point(pickup_lng, pickup_lat)::geography, ST_Point(dest_lng, dest_lat)::geography) / 1000 AS distance_km
- Fetch current surge_multiplier from surge_config (latest row)
- Fare formula: base_fare (2.50) + (distance_km * rate_per_km (1.20)) + (estimated_minutes * rate_per_min (0.25)) * surge_multiplier
- Estimated minutes: distance_km / 0.4 (average 24km/h urban speed)
- Return: { fare_cents: Math.round(fare * 100), distance_km, estimated_minutes, surge_multiplier, breakdown: { base, distance_charge, time_charge } }

2. supabase/functions/request-ride/index.ts
- Accept POST: { pickup_lat, pickup_lng, pickup_address, dest_lat, dest_lng, dest_address }
- Call calculate-fare internally to get fare details
- INSERT into rides with status='searching'
- Call find_nearby_drivers() Supabase RPC to get online drivers within 5km
- If no drivers found, return { status: 'no_drivers' }
- Insert a driver_requests row for the top 3 nearest drivers (first one to accept gets the ride)
- Return { ride_id, fare_cents, distance_km, drivers_notified: count }
```

> Pro tip: Add a request timeout: if no driver accepts within 3 minutes, update the ride status to 'cancelled' and notify the rider via a rides Realtime subscription UPDATE event. Use a Supabase scheduled function or a pg_cron job to handle expired ride requests automatically.

**Expected result:** The calculate-fare Edge Function returns a fare breakdown for any two coordinate pairs. The request-ride function creates a ride row and notifies nearby drivers. The ride status starts as 'searching'.

### 4. Build the map component with Leaflet.js

Integrate Leaflet.js for the interactive map. Show the driver's live location as a moving pin, the pickup point as a green marker, and the destination as a red marker. Update the driver pin smoothly as Broadcast events arrive.

```
Build a RideMap component at src/components/RideMap.tsx using Leaflet.js.

Requirements:
- Import Leaflet via CDN link in index.html (add the CSS and JS links, not npm — Lovable handles external scripts more reliably this way)
- Create a div with id='map' and className='w-full h-64 rounded-lg'
- Initialize the Leaflet map in a useEffect on mount, centered on the pickup coordinates
- Three markers:
  - Pickup: green circle marker with a 'P' label
  - Destination: red circle marker with a 'D' label
  - Driver: a car icon custom marker (use a DivIcon with a car emoji character)
- Subscribe to useRiderLocationSubscription(rideId) and update the driver marker position when driverLocation changes: driverMarker.setLatLng([lat, lng])
- Smooth animation: use Leaflet's marker.setLatLng with a pan animation when the driver is within view
- Show a polyline from the driver's current position to the pickup point using L.polyline([driverPos, pickupPos], { color: 'blue', dashArray: '5, 10' })
- Props: pickupLat, pickupLng, destLat, destLng, rideId (nullable)
- When rideId is null, show only pickup and destination markers
```

> Pro tip: Destroy and reinitialize the Leaflet map if the component unmounts and remounts (React StrictMode mounts twice). Store the map instance in a useRef and check if it is already initialized before calling L.map(). Use the same pattern for markers — store them in refs and update them instead of recreating.

**Expected result:** The map renders showing pickup and destination markers. When a ride is active, the driver's pin moves smoothly as Broadcast location events arrive. The blue dashed polyline connects the driver to the pickup point.

### 5. Build the rating system and ride completion flow

Create the post-ride rating flow for both rider and driver. After the ride status changes to 'completed', both parties see a rating Dialog. Ratings are stored on the ride row and aggregated into the driver's average_rating.

```
Build the post-ride rating flow.

Requirements:

1. Subscribe to Realtime UPDATE events on rides for the active ride_id. When status changes to 'completed', open a Rating Dialog automatically.

2. Rating Dialog component (src/components/RatingDialog.tsx):
   - Star rating input: 5 star icons, clicking fills stars 1 through N. Use a HoverCard or simple onClick handler to track selected star count.
   - Optional text comment textarea (max 200 chars)
   - 'Submit Rating' Button
   - 'Skip' Button (sets a default 5-star rating)

3. On submit:
   - For rider rating the driver: UPDATE rides SET driver_rating = stars WHERE id = rideId
   - For driver rating the rider: UPDATE rides SET rider_rating = stars WHERE id = rideId
   - After both ratings are submitted, recalculate the driver's average_rating: call an RPC function update_driver_rating(driver_id) that recomputes the average from all completed rides

4. RPC function update_driver_rating(p_driver_id uuid):
   UPDATE driver_profiles
   SET average_rating = (
     SELECT AVG(driver_rating)::numeric(3,1)
     FROM rides
     WHERE driver_id = p_driver_id AND driver_rating IS NOT NULL
   ), total_rides = (SELECT COUNT(*) FROM rides WHERE driver_id = p_driver_id AND status = 'completed')
   WHERE id = p_driver_id

5. Show the driver's average_rating Badge (a star icon + number) on the driver card during the ride
```

> Pro tip: Subscribe to the ride's Realtime UPDATE event from the moment the ride is created, not just when it becomes active. This ensures the rating Dialog opens automatically even if the user navigates away and returns — the Realtime event fires when status='completed' regardless of when the subscription was started.

**Expected result:** When the ride completes, a rating Dialog appears automatically. Submitting a rating updates the rides table. The driver's average_rating Badge on their card reflects the new score.

## Complete code example

File: `src/hooks/useRideStatus.ts`

```typescript
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'

type RideStatus =
  | 'searching'
  | 'driver_assigned'
  | 'en_route'
  | 'arrived'
  | 'in_progress'
  | 'completed'
  | 'cancelled'

type Ride = {
  id: string
  status: RideStatus
  driver_id: string | null
  fare_cents: number
  distance_km: number
  driver_rating: number | null
  rider_rating: number | null
}

export function useRideStatus(rideId: string | null) {
  const [ride, setRide] = useState<Ride | null>(null)
  const [showRatingDialog, setShowRatingDialog] = useState(false)

  useEffect(() => {
    if (!rideId) return

    supabase
      .from('rides')
      .select('id, status, driver_id, fare_cents, distance_km, driver_rating, rider_rating')
      .eq('id', rideId)
      .single()
      .then(({ data }) => setRide(data))

    const channel = supabase
      .channel(`ride-status:${rideId}`)
      .on(
        'postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'rides', filter: `id=eq.${rideId}` },
        (payload) => {
          const updated = payload.new as Ride
          setRide(updated)
          if (updated.status === 'completed' && updated.driver_rating === null) {
            setShowRatingDialog(true)
          }
        }
      )
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [rideId])

  const updateStatus = async (newStatus: RideStatus) => {
    if (!rideId) return
    const { error } = await supabase
      .from('rides')
      .update({ status: newStatus, ...(newStatus === 'in_progress' ? { started_at: new Date().toISOString() } : {}), ...(newStatus === 'completed' ? { completed_at: new Date().toISOString() } : {}) })
      .eq('id', rideId)
    if (error) console.error('Status update failed:', error.message)
  }

  const submitRating = async (stars: number, role: 'rider' | 'driver') => {
    if (!rideId) return
    const field = role === 'rider' ? 'driver_rating' : 'rider_rating'
    await supabase.from('rides').update({ [field]: stars }).eq('id', rideId)
    if (role === 'rider' && ride?.driver_id) {
      await supabase.rpc('update_driver_rating', { p_driver_id: ride.driver_id })
    }
    setShowRatingDialog(false)
  }

  return { ride, showRatingDialog, updateStatus, submitRating }
}
```

## Common mistakes

- **Using postgres_changes for driver location updates** — Writing every GPS ping (every 3 seconds per driver) to the database creates enormous write load. With 100 active drivers, that is 2,000 database INSERTs per minute just for location data, quickly exhausting Supabase Free plan row limits. Fix: Use Supabase Realtime Broadcast for high-frequency location streams. Broadcast sends messages directly over WebSocket without database writes. Persist to driver_locations only every 30 seconds for last-known location.
- **Skipping the PostGIS GIST index on geography columns** — Without the GIST index, ST_DWithin and ST_Distance perform full table scans on every driver lookup. With 10,000 driver location rows, each ride request triggers a slow full-scan query. Fix: CREATE INDEX ON driver_locations USING GIST(location). This reduces proximity lookups from O(n) sequential scans to O(log n) index scans. Always create this index before loading real data.
- **Reinitializing the Leaflet map on every render** — Leaflet's L.map() call fails if a map is already initialized on the same DOM element. In React, useEffect runs on re-render unless dependencies are carefully managed, causing 'Map container is already initialized' errors. Fix: Store the map instance in a useRef and guard initialization with if (mapRef.current) return. Tear down with map.remove() in the useEffect cleanup function.
- **Allowing riders to directly update ride status** — If riders can call supabase.from('rides').update({ status: 'completed' }) directly, they can mark rides as complete without paying or before the driver confirms arrival. Fix: Handle all status transitions in Edge Functions or Postgres functions with SECURITY DEFINER. Validate that the caller's role is allowed to make each transition: riders can cancel searching rides; drivers can update from driver_assigned through to completed.

## Best practices

- Use Realtime Broadcast for high-frequency ephemeral data (location pings) and postgres_changes for low-frequency persistent data (ride status changes). Never write every GPS ping to the database.
- Enable PostGIS and create GIST indexes on all geography columns before writing proximity queries. A missing index causes full table scans on every driver match request.
- Model ride status as a state machine with defined allowed transitions. Enforce transitions in a server-side function so clients cannot set arbitrary status values.
- Upsert driver locations (ON CONFLICT ON CONSTRAINT driver_locations_driver_id_key DO UPDATE) rather than insert-then-delete. One row per driver stays efficient.
- Always destroy and reinitialize Leaflet map instances in useEffect cleanup. React's strict mode double-invocation catches initialization bugs early.
- Include a completed_at timestamp on rides for fare disputes and earnings reporting. Never rely on created_at minus requested_at for duration — the ride may have waited before starting.
- Cap surge multiplier at 3x in the surge_config table via a CHECK constraint. Uncapped surge pricing causes bad user experiences and potential regulatory issues.

## Frequently asked questions

### Do I need PostGIS for driver matching or can I use a simpler approach?

For small scale (under 1,000 drivers), a haversine formula in JavaScript works: calculate straight-line distance client-side or in an Edge Function. For production scale, PostGIS with a GIST index is necessary. ST_DWithin uses the index to find candidates in milliseconds regardless of how many drivers are in the table. Without PostGIS, you would query all online drivers and filter in application code — slow and unscalable.

### How does Broadcast handle connection drops — will the rider miss location updates?

Broadcast events are ephemeral. If the rider's connection drops, they miss pings that were sent during the outage. When they reconnect, they receive the next ping (within 3 seconds). For a smoother experience, fall back to the driver_locations table (updated every 30 seconds) on reconnection to show the last known position while waiting for the Broadcast stream to resume.

### How do I prevent a driver from accepting a ride that another driver already accepted?

Use a Postgres function with SELECT FOR UPDATE SKIP LOCKED on the rides table. The function checks that status is still 'searching', locks the row, updates to 'driver_assigned', and returns the ride. If two drivers call it simultaneously, one gets the lock and succeeds; the other's SKIP LOCKED immediately returns empty and the Edge Function returns a 'ride already taken' response.

### Can I use a free API for routing instead of calculating straight-line distance?

Yes. OpenRouteService and OSRM have free tiers that return actual road distances and travel times. Call them from your calculate-fare Edge Function using fetch(). For development, straight-line distance with a 1.3x road factor is accurate enough. For production fare accuracy, real routing distance is worth the API call cost — Mapbox Directions is another popular option with a free tier of 100,000 requests/month.

### How do I handle the driver app location when the mobile browser goes to the background?

Mobile browsers stop executing JavaScript when backgrounded, which halts location broadcasts. For production, the driver app would need to be a native mobile app (React Native) using background location services. For a Lovable web app, add a prominent warning: 'Keep this tab open while driving for live tracking.' You can also detect page visibility changes and warn the driver when the page becomes hidden.

### How do I add payment processing to the ride completion?

Integrate Stripe in the ride request flow. When the rider confirms the fare estimate, create a Stripe PaymentIntent via an Edge Function for the fare amount. Store the payment_intent_id on the ride row. On ride completion, capture the PaymentIntent via another Edge Function. Use Stripe Webhooks (payment_intent.succeeded) to confirm payment before marking the ride as fully completed.

### How many concurrent rides can Supabase Realtime handle?

Each active ride needs two subscriptions: one Broadcast channel for location (driver + rider = 2 connections) and one postgres_changes channel for status (rider = 1 connection). That is 3 Realtime connections per active ride. Supabase Free plan supports 200 concurrent connections, so roughly 65 simultaneous active rides. Upgrade to Pro (500 connections) for larger scale.

### How do I show the route polyline from pickup to destination on the map?

Fetch the route coordinates from a routing API (Mapbox Directions, OpenRouteService) in the Edge Function that creates the ride. Store the route as a JSONB array of [lng, lat] pairs on the ride row. In the map component, use L.polyline(routeCoordinates, { color: 'gray' }) to draw the planned route. Overlay the driver's actual path as a different color using L.polyline that grows with each location broadcast received.

---

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