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.
What you're building
A ride-hailing platform has two real-time challenges: location tracking and ride status. Location tracking is high-frequency (every 3-5 seconds) but ephemeral — you don't need a database row for every GPS ping. Ride status is low-frequency but must be persisted and auditable.
For location, use Supabase Realtime Broadcast instead of postgres_changes. Broadcast sends messages directly over WebSocket without writing to the database first. A driver's app calls channel.send({ type: 'broadcast', event: 'location', payload: { lat, lng, heading } }) every 3 seconds. The rider's map component subscribes to the same channel and updates the driver marker in real time. This approach handles hundreds of concurrent location streams without any database write load.
Driver matching uses PostGIS. When a rider requests a ride, an Edge Function calls ST_DWithin(driver_location, pickup_point, 5000) to find drivers within 5km. It then sorts by ST_Distance and assigns the nearest available driver. Drivers have a real-time location stored in a driver_locations table (one row per driver, upserted every 30 seconds for persistence) and the ephemeral Broadcast stream for live tracking.
The fare calculation Edge Function computes base fare plus per-km and per-minute rates. It fetches the distance from a routing API (or approximates with ST_Distance), multiplies by a surge factor stored in a config table, and returns the fare breakdown before the rider confirms the ride.
Final result
A functional ride-hailing platform with live location tracking, driver matching, map display, fare calculation, and ratings.
Tech stack
Prerequisites
- 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
Build steps
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.
1Set up a ride-hailing platform schema in Supabase with PostGIS.23First, enable PostGIS: CREATE EXTENSION IF NOT EXISTS postgis;45Tables:671. 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_at892. driver_locations: id, driver_id (references driver_profiles, UNIQUE), location (geography(Point, 4326)), heading (int), speed_kmh (int), updated_at10113. 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_at12134. surge_config: id, area_name (text), multiplier (numeric default 1.0), updated_at1415RLS:16- driver_profiles: public SELECT, own UPDATE17- driver_locations: public SELECT (riders need to see driver positions), driver own INSERT/UPDATE18- 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 ride1920Index: CREATE INDEX ON driver_locations USING GIST(location);21Index: 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.
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.
1// src/hooks/useDriverLocationBroadcast.ts2// Runs in the DRIVER's app — sends location every 3 seconds3import { useEffect, useRef } from 'react'4import { supabase } from '@/lib/supabase'5import { useAuth } from '@/hooks/useAuth'67export function useDriverLocationBroadcast(rideId: string | null, isOnline: boolean) {8 const { user } = useAuth()9 const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)10 const intervalRef = useRef<ReturnType<typeof setInterval>>()1112 useEffect(() => {13 if (!rideId || !isOnline || !user?.id) return1415 const channel = supabase.channel(`ride:${rideId}:location`)16 channelRef.current = channel17 channel.subscribe()1819 intervalRef.current = setInterval(() => {20 if (!navigator.geolocation) return21 navigator.geolocation.getCurrentPosition((pos) => {22 channel.send({23 type: 'broadcast',24 event: 'location',25 payload: {26 driver_id: user.id,27 lat: pos.coords.latitude,28 lng: pos.coords.longitude,29 heading: pos.coords.heading ?? 0,30 timestamp: Date.now(),31 },32 })33 })34 }, 3000)3536 return () => {37 clearInterval(intervalRef.current)38 supabase.removeChannel(channel)39 }40 }, [rideId, isOnline, user?.id])41}4243// src/hooks/useRiderLocationSubscription.ts44// Runs in the RIDER's app — receives driver location45import { useEffect, useState } from 'react'46import { supabase } from '@/lib/supabase'4748type DriverLocation = { driver_id: string; lat: number; lng: number; heading: number }4950export function useRiderLocationSubscription(rideId: string | null) {51 const [driverLocation, setDriverLocation] = useState<DriverLocation | null>(null)5253 useEffect(() => {54 if (!rideId) return55 const channel = supabase56 .channel(`ride:${rideId}:location`)57 .on('broadcast', { event: 'location' }, (payload) => {58 setDriverLocation(payload.payload as DriverLocation)59 })60 .subscribe()61 return () => { supabase.removeChannel(channel) }62 }, [rideId])6364 return { driverLocation }65}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.
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.
1Create two Edge Functions:231. supabase/functions/calculate-fare/index.ts4- Accept POST: { pickup_lat, pickup_lng, dest_lat, dest_lng }5- 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_km6- Fetch current surge_multiplier from surge_config (latest row)7- Fare formula: base_fare (2.50) + (distance_km * rate_per_km (1.20)) + (estimated_minutes * rate_per_min (0.25)) * surge_multiplier8- Estimated minutes: distance_km / 0.4 (average 24km/h urban speed)9- Return: { fare_cents: Math.round(fare * 100), distance_km, estimated_minutes, surge_multiplier, breakdown: { base, distance_charge, time_charge } }10112. supabase/functions/request-ride/index.ts12- Accept POST: { pickup_lat, pickup_lng, pickup_address, dest_lat, dest_lng, dest_address }13- Call calculate-fare internally to get fare details14- INSERT into rides with status='searching'15- Call find_nearby_drivers() Supabase RPC to get online drivers within 5km16- If no drivers found, return { status: 'no_drivers' }17- Insert a driver_requests row for the top 3 nearest drivers (first one to accept gets the ride)18- 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'.
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.
1Build a RideMap component at src/components/RideMap.tsx using Leaflet.js.23Requirements:4- Import Leaflet via CDN link in index.html (add the CSS and JS links, not npm — Lovable handles external scripts more reliably this way)5- Create a div with id='map' and className='w-full h-64 rounded-lg'6- Initialize the Leaflet map in a useEffect on mount, centered on the pickup coordinates7- Three markers:8 - Pickup: green circle marker with a 'P' label9 - Destination: red circle marker with a 'D' label10 - Driver: a car icon custom marker (use a DivIcon with a car emoji character)11- Subscribe to useRiderLocationSubscription(rideId) and update the driver marker position when driverLocation changes: driverMarker.setLatLng([lat, lng])12- Smooth animation: use Leaflet's marker.setLatLng with a pan animation when the driver is within view13- Show a polyline from the driver's current position to the pickup point using L.polyline([driverPos, pickupPos], { color: 'blue', dashArray: '5, 10' })14- Props: pickupLat, pickupLng, destLat, destLng, rideId (nullable)15- When rideId is null, show only pickup and destination markersPro 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.
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.
1Build the post-ride rating flow.23Requirements:451. Subscribe to Realtime UPDATE events on rides for the active ride_id. When status changes to 'completed', open a Rating Dialog automatically.672. Rating Dialog component (src/components/RatingDialog.tsx):8 - Star rating input: 5 star icons, clicking fills stars 1 through N. Use a HoverCard or simple onClick handler to track selected star count.9 - Optional text comment textarea (max 200 chars)10 - 'Submit Rating' Button11 - 'Skip' Button (sets a default 5-star rating)12133. On submit:14 - For rider rating the driver: UPDATE rides SET driver_rating = stars WHERE id = rideId15 - For driver rating the rider: UPDATE rides SET rider_rating = stars WHERE id = rideId16 - 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 rides17184. RPC function update_driver_rating(p_driver_id uuid):19 UPDATE driver_profiles20 SET average_rating = (21 SELECT AVG(driver_rating)::numeric(3,1)22 FROM rides23 WHERE driver_id = p_driver_id AND driver_rating IS NOT NULL24 ), total_rides = (SELECT COUNT(*) FROM rides WHERE driver_id = p_driver_id AND status = 'completed')25 WHERE id = p_driver_id26275. Show the driver's average_rating Badge (a star icon + number) on the driver card during the ridePro 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
1import { useEffect, useState } from 'react'2import { supabase } from '@/lib/supabase'34type RideStatus =5 | 'searching'6 | 'driver_assigned'7 | 'en_route'8 | 'arrived'9 | 'in_progress'10 | 'completed'11 | 'cancelled'1213type Ride = {14 id: string15 status: RideStatus16 driver_id: string | null17 fare_cents: number18 distance_km: number19 driver_rating: number | null20 rider_rating: number | null21}2223export function useRideStatus(rideId: string | null) {24 const [ride, setRide] = useState<Ride | null>(null)25 const [showRatingDialog, setShowRatingDialog] = useState(false)2627 useEffect(() => {28 if (!rideId) return2930 supabase31 .from('rides')32 .select('id, status, driver_id, fare_cents, distance_km, driver_rating, rider_rating')33 .eq('id', rideId)34 .single()35 .then(({ data }) => setRide(data))3637 const channel = supabase38 .channel(`ride-status:${rideId}`)39 .on(40 'postgres_changes',41 { event: 'UPDATE', schema: 'public', table: 'rides', filter: `id=eq.${rideId}` },42 (payload) => {43 const updated = payload.new as Ride44 setRide(updated)45 if (updated.status === 'completed' && updated.driver_rating === null) {46 setShowRatingDialog(true)47 }48 }49 )50 .subscribe()5152 return () => { supabase.removeChannel(channel) }53 }, [rideId])5455 const updateStatus = async (newStatus: RideStatus) => {56 if (!rideId) return57 const { error } = await supabase58 .from('rides')59 .update({ status: newStatus, ...(newStatus === 'in_progress' ? { started_at: new Date().toISOString() } : {}), ...(newStatus === 'completed' ? { completed_at: new Date().toISOString() } : {}) })60 .eq('id', rideId)61 if (error) console.error('Status update failed:', error.message)62 }6364 const submitRating = async (stars: number, role: 'rider' | 'driver') => {65 if (!rideId) return66 const field = role === 'rider' ? 'driver_rating' : 'rider_rating'67 await supabase.from('rides').update({ [field]: stars }).eq('id', rideId)68 if (role === 'rider' && ride?.driver_id) {69 await supabase.rpc('update_driver_rating', { p_driver_id: ride.driver_id })70 }71 setShowRatingDialog(false)72 }7374 return { ride, showRatingDialog, updateStatus, submitRating }75}Customization ideas
Surge pricing based on demand
Add a scheduled Edge Function that runs every 5 minutes and counts active ride requests per geographic area. If requests exceed a threshold, update surge_config with a multiplier of 1.5x or 2x. Show the surge multiplier badge prominently in the fare estimate UI with an explanation tooltip.
Driver earnings dashboard
Build a driver-facing earnings page with Recharts charts showing: total earnings per day (BarChart), rides per hour heatmap, average fare per ride, and total distance driven. All metrics come from aggregating the rides table filtered by driver_id and status='completed'.
Scheduled rides (book in advance)
Add a pickup_scheduled_at column to rides. Riders can schedule a ride up to 7 days in advance. A Supabase cron Edge Function runs every minute and triggers the driver matching algorithm for rides where pickup_scheduled_at is within the next 15 minutes and status is still 'pending_scheduled'.
Multi-stop rides
Replace the single destination_location with a stops jsonb array containing ordered waypoints. Display all stops on the map with numbered markers. Calculate fare as the sum of distances between each consecutive pair of stops multiplied by the per-km rate.
Driver heat map for fleet management
Build an admin view showing all online drivers as a Leaflet heat map layer using the leaflet-heat plugin. Display request density as a separate heat layer. Admins can see supply-demand mismatches in real time and adjust surge pricing manually per area.
Common pitfalls
Pitfall: Using postgres_changes for driver location updates
How to avoid: 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.
Pitfall: Skipping the PostGIS GIST index on geography columns
How to avoid: 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.
Pitfall: Reinitializing the Leaflet map on every render
How to avoid: 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.
Pitfall: Allowing riders to directly update ride status
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a ride-hailing platform with Supabase and PostGIS. I need a driver matching function that finds the 3 nearest online drivers to a pickup point and assigns the ride to the first one who accepts. How do I handle the race condition where two drivers accept the same ride simultaneously? Show me the PostgreSQL function using SELECT FOR UPDATE SKIP LOCKED that atomically claims a ride for exactly one driver, even under concurrent requests.
Add a driver acceptance flow. When a new ride is in 'searching' status, online drivers within 5km see a notification Banner at the top of their screen showing: pickup address, destination, estimated earnings, and estimated pickup distance. Add 'Accept' and 'Decline' Buttons. Accepting calls an Edge Function that sets the ride driver_id and status='driver_assigned' using SELECT FOR UPDATE SKIP LOCKED to prevent double-acceptance. The Banner disappears for all other drivers when one accepts.
In Supabase, enable the pg_cron extension and create a cron job that runs every 5 minutes: SELECT cron.schedule('expire-old-rides', '*/5 * * * *', $$ UPDATE rides SET status = 'cancelled' WHERE status = 'searching' AND requested_at < now() - interval '5 minutes' $$). This automatically cancels ride requests that no driver accepted within 5 minutes. Also create a trigger that fires after this update and inserts a notification row for the rider.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation