Build a simplified ride-hailing platform in Replit in 2-4 hours. Use Replit Agent to generate an Express + PostgreSQL app with driver-rider matching, Haversine distance queries, GPS coordinate tracking via polling, fare estimation using a routing API, Stripe Connect driver payouts, and mutual post-ride ratings. Deploy on Reserved VM for always-on GPS polling.
What you're building
A ride-hailing platform is technically one of the most demanding apps to build — it requires real-time location tracking, dynamic pricing, two-sided user roles, and split financial transfers. This guide builds a simplified but fully functional version: riders request rides with pickup and dropoff coordinates, the system finds the nearest available driver using the Haversine formula, and the driver accepts and navigates to the pickup.
Replit Agent generates the full Express backend from a single prompt. The GPS tracking system uses polling instead of WebSockets: the driver posts their coordinates every 5 seconds, and the rider polls the latest position every 5 seconds. This is intentionally simpler than a WebSocket approach — it works reliably on Replit and avoids the complexity of persistent socket connections. Fare estimation calls the free OpenRouteService API to calculate distance and duration between the two points, then applies a base fare plus per-km and per-minute rate.
Stripe Connect handles driver payouts. This is a manual Stripe integration — the Replit /stripe command does not support Connect flows. Drivers onboard via Stripe Express, and after ride completion the platform initiates a transfer to the driver's connected account. Deploy on Reserved VM: the constant GPS polling and the webhook endpoint for Stripe payout events both require an always-running server.
Final result
A fully functional ride-hailing API with driver-rider matching, GPS coordinate tracking, Haversine-based driver discovery, fare estimation, Stripe Connect driver payouts, and mutual ratings — deployed on Replit Reserved VM.
Tech stack
Prerequisites
- A Replit Core account or higher (Reserved VM deployment required for always-on GPS polling)
- A Stripe account with Connect enabled (Settings → Connect → Enable Stripe Connect)
- STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET stored in Replit Secrets (open Secrets via lock icon)
- An OpenRouteService API key (free at openrouteservice.org) stored in Replit Secrets as ORS_API_KEY
- Basic understanding of latitude/longitude coordinates and what GPS tracking means
Build steps
Scaffold the project with Replit Agent
Create a new Repl and use the Agent prompt below to generate the full ride-hailing schema and routes. Install the Stripe SDK manually — do NOT use the /stripe command, which does not support Connect flows.
1// Type this into Replit Agent:2// Build a ride-hailing platform with Express and PostgreSQL using Drizzle ORM.3// Install stripe npm package manually (DO NOT use /stripe command).4// Tables:5// - users: id serial pk, user_id text not null unique (Replit Auth ID),6// type text not null (enum rider/driver), name text not null,7// phone text, email text, created_at timestamp default now()8// - drivers: id serial, user_id integer FK users not null unique,9// vehicle_make text, vehicle_model text, vehicle_year integer,10// license_plate text, is_online boolean default false,11// current_lat numeric, current_lng numeric,12// rating_avg numeric default 5.0, total_rides integer default 0,13// stripe_account_id text, payout_status text default 'pending'14// - rides: id serial, rider_id integer FK users not null,15// driver_id integer FK users, pickup_lat numeric not null,16// pickup_lng numeric not null, pickup_address text,17// dropoff_lat numeric not null, dropoff_lng numeric not null,18// dropoff_address text,19// status text default 'requested' (enum requested/matched/driver_en_route/in_progress/completed/cancelled),20// fare_estimate integer not null (cents), fare_actual integer,21// distance_km numeric, duration_minutes integer,22// started_at timestamp, completed_at timestamp,23// created_at timestamp default now()24// - ride_tracking: id serial, ride_id integer FK rides not null,25// lat numeric not null, lng numeric not null,26// recorded_at timestamp default now()27// - ratings: id serial, ride_id integer FK rides not null unique,28// rider_rating integer, driver_rating integer,29// rider_comment text, driver_comment text,30// created_at timestamp default now()31// Routes:32// POST /api/rides/request — fare estimate + create ride33// POST /api/rides/:id/accept — driver accepts ride34// PATCH /api/rides/:id/status — advance status35// POST /api/rides/:id/cancel — cancel36// POST /api/rides/:id/track — driver posts GPS coordinates37// GET /api/rides/:id/track — rider polls latest driver position38// POST /api/rides/:id/rate — mutual post-ride rating39// PATCH /api/drivers/online — toggle driver availability + update GPS40// GET /api/drivers/nearby — find online drivers within radius41// GET /api/rides/active — current active ride42// GET /api/rides/history — past rides43// POST /api/drivers/onboard — Stripe Connect Express onboarding44// POST /api/webhooks/stripe — raw body, Stripe payouts webhook45// Register webhook route BEFORE express.json(). Use Replit Auth. Bind server to 0.0.0.0.Pro tip: Install the Stripe SDK in the Replit packages panel or Shell tab: npm install stripe. Also install node-fetch for the OpenRouteService API calls if Agent does not add it automatically.
Expected result: A running Express app with all five tables created. The packages panel shows stripe installed. The console shows 'Ride-hailing server running on port 5000'.
Build the fare estimation and ride request route
When a rider requests a ride, the route calls OpenRouteService to get the driving distance and duration, calculates a fare, and finds the nearest online driver using the Haversine formula.
1const fetch = require('node-fetch');2const { rides, drivers, users } = require('../../shared/schema');3const { eq, and, sql } = require('drizzle-orm');45const BASE_FARE_CENTS = 200; // $2.00 base6const PER_KM_CENTS = 120; // $1.20 per km7const PER_MIN_CENTS = 25; // $0.25 per minute89async function estimateFare(pickupLat, pickupLng, dropoffLat, dropoffLng) {10 const url = `https://api.openrouteservice.org/v2/directions/driving-car?api_key=${process.env.ORS_API_KEY}&start=${pickupLng},${pickupLat}&end=${dropoffLng},${dropoffLat}`;11 const resp = await fetch(url);12 const data = await resp.json();13 const segment = data.features?.[0]?.properties?.segments?.[0];14 const distanceKm = segment ? segment.distance / 1000 : 5;15 const durationMin = segment ? segment.duration / 60 : 10;16 const fare = BASE_FARE_CENTS + Math.round(distanceKm * PER_KM_CENTS) + Math.round(durationMin * PER_MIN_CENTS);17 return { fare, distanceKm: Math.round(distanceKm * 10) / 10, durationMin: Math.round(durationMin) };18}1920// Haversine distance in km between two lat/lng points21function haversine(lat1, lng1, lat2, lng2) {22 const R = 6371;23 const dLat = (lat2 - lat1) * Math.PI / 180;24 const dLng = (lng2 - lng1) * Math.PI / 180;25 const a = Math.sin(dLat / 2) ** 2 +26 Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;27 return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));28}2930// POST /api/rides/request31router.post('/rides/request', async (req, res) => {32 const userId = req.user?.id;33 if (!userId) return res.status(401).json({ error: 'Login required' });3435 const { pickupLat, pickupLng, pickupAddress, dropoffLat, dropoffLng, dropoffAddress } = req.body;3637 const { fare, distanceKm, durationMin } = await estimateFare(pickupLat, pickupLng, dropoffLat, dropoffLng);3839 const [rider] = await db.select().from(users).where(eq(users.userId, userId));40 if (!rider) return res.status(404).json({ error: 'User profile not found' });4142 const [ride] = await db.insert(rides).values({43 riderId: rider.id,44 pickupLat, pickupLng, pickupAddress,45 dropoffLat, dropoffLng, dropoffAddress,46 fareEstimate: fare,47 distanceKm,48 durationMinutes: durationMin,49 status: 'requested',50 }).returning();5152 res.status(201).json({ ride, fareEstimate: fare, distanceKm, durationMin });53});Pro tip: Store ORS_API_KEY in Replit Secrets (lock icon in sidebar). The free OpenRouteService plan allows 2,000 requests per day — sufficient for testing. For production, consider caching fare estimates by coordinate pair.
Expected result: POST /api/rides/request returns a ride object with fareEstimate in cents and the calculated distance and duration. A ride record is created with status 'requested'.
Build GPS tracking and nearby driver discovery
The driver posts their GPS coordinates every 5 seconds via PATCH /api/drivers/online. The rider polls GET /api/rides/:id/track to see the latest driver position. The nearby driver query uses Haversine in SQL to find drivers within 5km of the pickup.
1// PATCH /api/drivers/online — driver toggles availability and updates GPS2router.patch('/drivers/online', async (req, res) => {3 const userId = req.user?.id;4 if (!userId) return res.status(401).json({ error: 'Login required' });56 const { isOnline, lat, lng } = req.body;78 const [user] = await db.select().from(users).where(eq(users.userId, userId));9 const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));10 if (!driver) return res.status(404).json({ error: 'Driver profile not found' });1112 const [updated] = await db.update(drivers)13 .set({ isOnline, currentLat: lat, currentLng: lng })14 .where(eq(drivers.id, driver.id))15 .returning();1617 res.json(updated);18});1920// POST /api/rides/:id/track — driver posts current GPS position21router.post('/rides/:id/track', async (req, res) => {22 const { lat, lng } = req.body;23 await db.insert(rideTracking).values({24 rideId: parseInt(req.params.id),25 lat, lng,26 });27 // Also update driver's current_lat/lng for nearby queries28 const userId = req.user?.id;29 const [user] = await db.select().from(users).where(eq(users.userId, userId));30 const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));31 if (driver) {32 await db.update(drivers).set({ currentLat: lat, currentLng: lng }).where(eq(drivers.id, driver.id));33 }34 res.json({ recorded: true });35});3637// GET /api/rides/:id/track — rider polls latest driver GPS position38router.get('/rides/:id/track', async (req, res) => {39 const [latest] = await db.select().from(rideTracking)40 .where(eq(rideTracking.rideId, parseInt(req.params.id)))41 .orderBy(desc(rideTracking.recordedAt))42 .limit(1);43 res.json(latest || null);44});4546// GET /api/drivers/nearby — find online drivers within 5km of pickup47router.get('/drivers/nearby', async (req, res) => {48 const { lat, lng, radius = 5 } = req.query;49 const nearbyDrivers = await db.execute(sql`50 SELECT d.*, u.name,51 acos(52 sin(radians(${parseFloat(lat)})) * sin(radians(d.current_lat)) +53 cos(radians(${parseFloat(lat)})) * cos(radians(d.current_lat)) *54 cos(radians(d.current_lng) - radians(${parseFloat(lng)}))55 ) * 6371 AS distance_km56 FROM drivers d57 JOIN users u ON u.id = d.user_id58 WHERE d.is_online = true59 AND d.current_lat IS NOT NULL60 HAVING acos(61 sin(radians(${parseFloat(lat)})) * sin(radians(d.current_lat)) +62 cos(radians(${parseFloat(lat)})) * cos(radians(d.current_lat)) *63 cos(radians(d.current_lng) - radians(${parseFloat(lng)}))64 ) * 6371 <= ${parseFloat(radius)}65 ORDER BY distance_km ASC66 LIMIT 1067 `);68 res.json(nearbyDrivers.rows);69});Expected result: GET /api/drivers/nearby returns a list of online drivers sorted by distance. POST /api/rides/1/track inserts a coordinate row. GET /api/rides/1/track returns the most recent driver position.
Build Stripe Connect driver onboarding and payout webhook
Drivers onboard via Stripe Connect Express. After a ride completes, the platform initiates a transfer to the driver's Stripe account. The webhook handler uses constructEvent (sync) to verify the signature.
1const Stripe = require('stripe');2const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);3const express = require('express');4const router = express.Router();56// POST /api/drivers/onboard — initiate Stripe Connect Express onboarding7router.post('/drivers/onboard', async (req, res) => {8 const userId = req.user?.id;9 const [user] = await db.select().from(users).where(eq(users.userId, userId));10 const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));11 if (!driver) return res.status(404).json({ error: 'Driver profile not found' });1213 let accountId = driver.stripeAccountId;14 if (!accountId) {15 const account = await stripe.accounts.create({16 type: 'express',17 capabilities: { transfers: { requested: true } },18 });19 accountId = account.id;20 await db.update(drivers).set({ stripeAccountId: accountId }).where(eq(drivers.id, driver.id));21 }2223 const link = await stripe.accountLinks.create({24 account: accountId,25 refresh_url: `${process.env.REPLIT_DEPLOYMENT_URL}/driver/onboard`,26 return_url: `${process.env.REPLIT_DEPLOYMENT_URL}/driver/dashboard`,27 type: 'account_onboarding',28 });2930 res.json({ url: link.url });31});3233// POST /api/webhooks/stripe — raw body required, register BEFORE express.json()34router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {35 const sig = req.headers['stripe-signature'];36 let event;37 try {38 // constructEvent is SYNC — not async39 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);40 } catch (err) {41 return res.status(400).json({ error: `Webhook verification failed: ${err.message}` });42 }4344 if (event.type === 'account.updated') {45 const account = event.data.object;46 if (account.charges_enabled && account.payouts_enabled) {47 await db.update(drivers)48 .set({ payoutStatus: 'active' })49 .where(eq(drivers.stripeAccountId, account.id));50 }51 }5253 res.json({ received: true });54});5556module.exports = router;Pro tip: Store REPLIT_DEPLOYMENT_URL in Replit Secrets before deploying. This URL is used in the Stripe Account Link's refresh_url and return_url. Using a dev URL here will break the onboarding redirect flow.
Expected result: POST /api/drivers/onboard returns a Stripe-hosted onboarding URL. After the driver completes identity verification, the account.updated webhook fires and sets driver.payout_status to 'active'.
Deploy on Reserved VM and register the webhook endpoint
Deploy on Reserved VM — the constant GPS polling and the Stripe webhook endpoint both require a server that is always running. After deploying, register your webhook URL in the Stripe Dashboard.
1// server/index.js — critical middleware order2const express = require('express');3const path = require('path');45const payoutsRouter = require('./routes/payouts'); // contains webhook route6const ridesRouter = require('./routes/rides');7const trackingRouter = require('./routes/tracking');89const app = express();1011// IMPORTANT: Register webhook route BEFORE express.json()12// Stripe needs the raw request body for signature verification13app.use('/api', payoutsRouter);1415// Then add JSON parsing for all other routes16app.use(express.json());17app.use('/api', ridesRouter);18app.use('/api', trackingRouter);1920// Serve static frontend21app.use(express.static(path.join(__dirname, '../client/dist')));22app.get('*', (req, res) => {23 res.sendFile(path.join(__dirname, '../client/dist/index.html'));24});2526// Bind to 0.0.0.0 — required for Replit27app.listen(5000, '0.0.0.0', () => console.log('Ride-hailing server running on port 5000'));2829// After deploying to Reserved VM:30// 1. Stripe Dashboard → Developers → Webhooks → Add endpoint31// 2. URL: https://your-repl.replit.app/api/webhooks/stripe32// 3. Select events: account.updated, transfer.created33// 4. Copy Signing Secret → Replit Secrets → STRIPE_WEBHOOK_SECRETExpected result: The app runs on Reserved VM with a persistent public URL. GPS polling works without cold start delays. The Stripe webhook is registered and payout_status updates after driver onboarding completes.
Complete code
1const express = require('express');2const { rides, drivers, users, rideTracking, ratings } = require('../../shared/schema');3const { eq, and, desc } = require('drizzle-orm');4const { db } = require('../db');56const router = express.Router();78// POST /api/rides/:id/accept — driver accepts a requested ride9router.post('/rides/:id/accept', async (req, res) => {10 const userId = req.user?.id;11 if (!userId) return res.status(401).json({ error: 'Login required' });1213 const [user] = await db.select().from(users).where(eq(users.userId, userId));14 const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));15 if (!driver) return res.status(403).json({ error: 'Driver profile required' });1617 const [ride] = await db.select().from(rides)18 .where(and(eq(rides.id, parseInt(req.params.id)), eq(rides.status, 'requested')));19 if (!ride) return res.status(404).json({ error: 'Ride not found or already accepted' });2021 const [updated] = await db.update(rides)22 .set({ driverId: user.id, status: 'matched' })23 .where(eq(rides.id, ride.id))24 .returning();2526 res.json(updated);27});2829// PATCH /api/rides/:id/status — advance through ride lifecycle30router.patch('/rides/:id/status', async (req, res) => {31 const { status } = req.body;32 const allowed = ['driver_en_route', 'in_progress', 'completed', 'cancelled'];33 if (!allowed.includes(status)) return res.status(400).json({ error: 'Invalid status' });3435 const updates = { status };36 if (status === 'in_progress') updates.startedAt = new Date();37 if (status === 'completed') updates.completedAt = new Date();3839 const [updated] = await db.update(rides).set(updates)40 .where(eq(rides.id, parseInt(req.params.id))).returning();41 res.json(updated);42});4344// POST /api/rides/:id/rate — mutual post-ride ratings45router.post('/rides/:id/rate', async (req, res) => {46 const userId = req.user?.id;47 if (!userId) return res.status(401).json({ error: 'Login required' });48 const { driverRating, riderRating, driverComment, riderComment } = req.body;49 const [rating] = await db.insert(ratings).values({50 rideId: parseInt(req.params.id),51 driverRating: driverRating || null,52 riderRating: riderRating || null,53 driverComment: driverComment || null,54 riderComment: riderComment || null,55 }).returning();56 res.status(201).json(rating);57});5859module.exports = router;Customization ideas
Surge pricing multiplier
Add a demand calculation that counts active ride requests within a 5km radius. If the request-to-driver ratio exceeds 2:1, apply a surge multiplier (e.g. 1.5x) to the fare estimate. Display the surge multiplier prominently to the rider before they confirm.
In-app chat between driver and rider
Add a ride_messages table with ride_id, sender_id, message, and created_at. Add POST /api/rides/:id/messages and GET /api/rides/:id/messages routes. The rider polls for new messages every 5 seconds alongside the GPS tracking poll.
Driver earnings dashboard
Add a GET /api/drivers/earnings route returning weekly earnings grouped by day: SUM of fare_actual for completed rides where driver_id matches, joined with the ratings table for average rating per week. Display as a bar chart in the driver dashboard.
Scheduled rides
Add a scheduled_at timestamp to the rides table. Add a background job (using setInterval in server.js on Reserved VM) that runs every minute and matches unmatched scheduled rides to the nearest online driver 10 minutes before the pickup time.
Common pitfalls
Pitfall: Using the /stripe Replit command instead of installing the SDK manually
How to avoid: Install stripe via npm install stripe in the Shell tab. Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Replit Secrets (lock icon) and import with const stripe = new Stripe(process.env.STRIPE_SECRET_KEY).
Pitfall: Deploying on Autoscale instead of Reserved VM
How to avoid: Deploy on Reserved VM ($10-20/month on Replit Core). This keeps the server running 24/7 and eliminates cold start delays for GPS polling and webhook reception.
Pitfall: Registering the Stripe webhook route after express.json() middleware
How to avoid: Register app.use('/api', payoutsRouter) before app.use(express.json()). Use express.raw({ type: 'application/json' }) only on the webhook route itself.
Pitfall: Hardcoding API keys instead of using Replit Secrets
How to avoid: Open the Secrets panel (lock icon in Replit sidebar). Add ORS_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and REPLIT_DEPLOYMENT_URL as individual secrets. Access them in code with process.env.KEY_NAME.
Best practices
- Store ORS_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and REPLIT_DEPLOYMENT_URL in Replit Secrets — never hardcode API keys in source files.
- Register the Stripe webhook route BEFORE express.json() middleware — constructEvent() requires the raw request body Buffer, not a parsed JSON object.
- Deploy on Reserved VM for ride-hailing apps — GPS polling every 5 seconds and Stripe webhook reception both require a server that is always running without cold start delays.
- Use the Haversine formula in SQL for nearby driver queries — running it in JavaScript would require fetching all online drivers to the application layer and filtering there, which is slow and wasteful.
- Validate ride status transitions in the PATCH /api/rides/:id/status route — only allow progression forward through the lifecycle to prevent riders or drivers from jumping to invalid states.
- Limit ride_tracking rows per ride with a cleanup job or a partial index — storing GPS coordinates every 5 seconds creates thousands of rows per ride. Delete rows older than 1 hour for completed rides.
- Use Drizzle Studio (built into Replit) to inspect the rides and drivers tables during testing — it makes it easy to manually toggle driver online status and verify status transitions work correctly.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a ride-hailing platform with Express, PostgreSQL, and Drizzle ORM on Replit. I need to implement: (1) a Haversine distance query in PostgreSQL that returns online drivers within a given radius of a pickup coordinate, ordered by distance; (2) a fare estimate function that calls the OpenRouteService Directions API to get driving distance and duration between two lat/lng coordinates and returns a fare in cents using base_fare + per_km_rate + per_minute_rate. Show me the SQL for the Haversine query and the Node.js function for the ORS API call.
Add a cancellation fee system to the ride-hailing platform. If a rider cancels more than 2 minutes after a driver has been matched and is en route, charge a $2.00 cancellation fee using Stripe PaymentIntents. Store the rider's saved payment method ID in the users table from a setup intent flow. When cancellation is requested, check the time elapsed since status changed to matched, and if greater than 120 seconds, create a PaymentIntent for 200 cents against the rider's saved method and transfer $1.50 to the driver's connected Stripe account as compensation.
Frequently asked questions
How does the real-time GPS tracking work without WebSockets?
The driver's mobile browser posts their coordinates to POST /api/rides/:id/track every 5 seconds using a setInterval call. The rider's frontend polls GET /api/rides/:id/track every 5 seconds to retrieve the latest coordinate row. This polling approach is simpler to build and debug than WebSockets, and works reliably on Replit's Reserved VM.
Why use the Haversine formula instead of a mapping API for driver discovery?
Finding nearby drivers is a fast SQL query — no external API calls needed. The Haversine formula calculates straight-line distance between two lat/lng coordinates directly in PostgreSQL. This runs in milliseconds and does not consume OpenRouteService API quota. The routing API is only called once when estimating a fare.
How do driver payouts work with Stripe Connect?
Drivers onboard via Stripe Express, which guides them through identity verification and bank account setup on a Stripe-hosted page. After a ride completes, the platform initiates a transfer from the platform Stripe account to the driver's connected Stripe account using stripe.transfers.create(). The transfer amount is fare_actual minus the platform commission.
What Replit plan do I need?
Replit Core or higher. This build requires Reserved VM deployment for two reasons: GPS polling creates constant inbound requests that would suffer from cold start delays on Autoscale, and the Stripe webhook endpoint must be running 24/7 to receive payout event confirmations.
Should I deploy on Autoscale or Reserved VM?
Reserved VM. GPS tracking polls every 5 seconds — Autoscale's 2-10 second cold start would create visible gaps in driver position updates. Stripe webhook events also require a server that is always listening. Reserved VM costs $10-20/month on Replit Core and eliminates both problems.
Does GPS tracking work on mobile browsers?
Yes, but only over HTTPS. Replit's deployment URLs (*.replit.app) are always HTTPS, so this works out of the box. The Geolocation API that the driver frontend uses to get the device's current position requires a secure context — it will not work over plain HTTP.
Can RapidDev help build a custom ride-hailing platform?
Yes. RapidDev has built 600+ apps including two-sided marketplace and mobility platforms with custom fare algorithms, surge pricing, and driver settlement workflows. Book a free consultation at rapidevelopers.com.
How do I prevent a driver from accepting multiple rides simultaneously?
Add a check in the POST /api/rides/:id/accept route that queries the rides table for any existing ride where driver_id = the current driver's id and status is in ('matched', 'driver_en_route', 'in_progress'). If one exists, return a 409 error with 'You already have an active ride'. Only allow acceptance if no active ride is found.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation