# How to Build a Ride Hailing Platform with Replit

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

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

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

```
// Type this into Replit Agent:
// Build a ride-hailing platform with Express and PostgreSQL using Drizzle ORM.
// Install stripe npm package manually (DO NOT use /stripe command).
// Tables:
// - users: id serial pk, user_id text not null unique (Replit Auth ID),
//   type text not null (enum rider/driver), name text not null,
//   phone text, email text, created_at timestamp default now()
// - drivers: id serial, user_id integer FK users not null unique,
//   vehicle_make text, vehicle_model text, vehicle_year integer,
//   license_plate text, is_online boolean default false,
//   current_lat numeric, current_lng numeric,
//   rating_avg numeric default 5.0, total_rides integer default 0,
//   stripe_account_id text, payout_status text default 'pending'
// - rides: id serial, rider_id integer FK users not null,
//   driver_id integer FK users, pickup_lat numeric not null,
//   pickup_lng numeric not null, pickup_address text,
//   dropoff_lat numeric not null, dropoff_lng numeric not null,
//   dropoff_address text,
//   status text default 'requested' (enum requested/matched/driver_en_route/in_progress/completed/cancelled),
//   fare_estimate integer not null (cents), fare_actual integer,
//   distance_km numeric, duration_minutes integer,
//   started_at timestamp, completed_at timestamp,
//   created_at timestamp default now()
// - ride_tracking: id serial, ride_id integer FK rides not null,
//   lat numeric not null, lng numeric not null,
//   recorded_at timestamp default now()
// - ratings: id serial, ride_id integer FK rides not null unique,
//   rider_rating integer, driver_rating integer,
//   rider_comment text, driver_comment text,
//   created_at timestamp default now()
// Routes:
// POST /api/rides/request — fare estimate + create ride
// POST /api/rides/:id/accept — driver accepts ride
// PATCH /api/rides/:id/status — advance status
// POST /api/rides/:id/cancel — cancel
// POST /api/rides/:id/track — driver posts GPS coordinates
// GET /api/rides/:id/track — rider polls latest driver position
// POST /api/rides/:id/rate — mutual post-ride rating
// PATCH /api/drivers/online — toggle driver availability + update GPS
// GET /api/drivers/nearby — find online drivers within radius
// GET /api/rides/active — current active ride
// GET /api/rides/history — past rides
// POST /api/drivers/onboard — Stripe Connect Express onboarding
// POST /api/webhooks/stripe — raw body, Stripe payouts webhook
// 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'.

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

```
const fetch = require('node-fetch');
const { rides, drivers, users } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');

const BASE_FARE_CENTS = 200;  // $2.00 base
const PER_KM_CENTS = 120;     // $1.20 per km
const PER_MIN_CENTS = 25;     // $0.25 per minute

async function estimateFare(pickupLat, pickupLng, dropoffLat, dropoffLng) {
  const url = `https://api.openrouteservice.org/v2/directions/driving-car?api_key=${process.env.ORS_API_KEY}&start=${pickupLng},${pickupLat}&end=${dropoffLng},${dropoffLat}`;
  const resp = await fetch(url);
  const data = await resp.json();
  const segment = data.features?.[0]?.properties?.segments?.[0];
  const distanceKm = segment ? segment.distance / 1000 : 5;
  const durationMin = segment ? segment.duration / 60 : 10;
  const fare = BASE_FARE_CENTS + Math.round(distanceKm * PER_KM_CENTS) + Math.round(durationMin * PER_MIN_CENTS);
  return { fare, distanceKm: Math.round(distanceKm * 10) / 10, durationMin: Math.round(durationMin) };
}

// Haversine distance in km between two lat/lng points
function haversine(lat1, lng1, lat2, lng2) {
  const R = 6371;
  const dLat = (lat2 - lat1) * Math.PI / 180;
  const dLng = (lng2 - lng1) * Math.PI / 180;
  const a = Math.sin(dLat / 2) ** 2 +
    Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

// POST /api/rides/request
router.post('/rides/request', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const { pickupLat, pickupLng, pickupAddress, dropoffLat, dropoffLng, dropoffAddress } = req.body;

  const { fare, distanceKm, durationMin } = await estimateFare(pickupLat, pickupLng, dropoffLat, dropoffLng);

  const [rider] = await db.select().from(users).where(eq(users.userId, userId));
  if (!rider) return res.status(404).json({ error: 'User profile not found' });

  const [ride] = await db.insert(rides).values({
    riderId: rider.id,
    pickupLat, pickupLng, pickupAddress,
    dropoffLat, dropoffLng, dropoffAddress,
    fareEstimate: fare,
    distanceKm,
    durationMinutes: durationMin,
    status: 'requested',
  }).returning();

  res.status(201).json({ ride, fareEstimate: fare, distanceKm, durationMin });
});
```

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

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

```
// PATCH /api/drivers/online — driver toggles availability and updates GPS
router.patch('/drivers/online', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const { isOnline, lat, lng } = req.body;

  const [user] = await db.select().from(users).where(eq(users.userId, userId));
  const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));
  if (!driver) return res.status(404).json({ error: 'Driver profile not found' });

  const [updated] = await db.update(drivers)
    .set({ isOnline, currentLat: lat, currentLng: lng })
    .where(eq(drivers.id, driver.id))
    .returning();

  res.json(updated);
});

// POST /api/rides/:id/track — driver posts current GPS position
router.post('/rides/:id/track', async (req, res) => {
  const { lat, lng } = req.body;
  await db.insert(rideTracking).values({
    rideId: parseInt(req.params.id),
    lat, lng,
  });
  // Also update driver's current_lat/lng for nearby queries
  const userId = req.user?.id;
  const [user] = await db.select().from(users).where(eq(users.userId, userId));
  const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));
  if (driver) {
    await db.update(drivers).set({ currentLat: lat, currentLng: lng }).where(eq(drivers.id, driver.id));
  }
  res.json({ recorded: true });
});

// GET /api/rides/:id/track — rider polls latest driver GPS position
router.get('/rides/:id/track', async (req, res) => {
  const [latest] = await db.select().from(rideTracking)
    .where(eq(rideTracking.rideId, parseInt(req.params.id)))
    .orderBy(desc(rideTracking.recordedAt))
    .limit(1);
  res.json(latest || null);
});

// GET /api/drivers/nearby — find online drivers within 5km of pickup
router.get('/drivers/nearby', async (req, res) => {
  const { lat, lng, radius = 5 } = req.query;
  const nearbyDrivers = await db.execute(sql`
    SELECT d.*, u.name,
      acos(
        sin(radians(${parseFloat(lat)})) * sin(radians(d.current_lat)) +
        cos(radians(${parseFloat(lat)})) * cos(radians(d.current_lat)) *
        cos(radians(d.current_lng) - radians(${parseFloat(lng)}))
      ) * 6371 AS distance_km
    FROM drivers d
    JOIN users u ON u.id = d.user_id
    WHERE d.is_online = true
      AND d.current_lat IS NOT NULL
    HAVING acos(
        sin(radians(${parseFloat(lat)})) * sin(radians(d.current_lat)) +
        cos(radians(${parseFloat(lat)})) * cos(radians(d.current_lat)) *
        cos(radians(d.current_lng) - radians(${parseFloat(lng)}))
      ) * 6371 <= ${parseFloat(radius)}
    ORDER BY distance_km ASC
    LIMIT 10
  `);
  res.json(nearbyDrivers.rows);
});
```

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

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

```
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const express = require('express');
const router = express.Router();

// POST /api/drivers/onboard — initiate Stripe Connect Express onboarding
router.post('/drivers/onboard', async (req, res) => {
  const userId = req.user?.id;
  const [user] = await db.select().from(users).where(eq(users.userId, userId));
  const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));
  if (!driver) return res.status(404).json({ error: 'Driver profile not found' });

  let accountId = driver.stripeAccountId;
  if (!accountId) {
    const account = await stripe.accounts.create({
      type: 'express',
      capabilities: { transfers: { requested: true } },
    });
    accountId = account.id;
    await db.update(drivers).set({ stripeAccountId: accountId }).where(eq(drivers.id, driver.id));
  }

  const link = await stripe.accountLinks.create({
    account: accountId,
    refresh_url: `${process.env.REPLIT_DEPLOYMENT_URL}/driver/onboard`,
    return_url: `${process.env.REPLIT_DEPLOYMENT_URL}/driver/dashboard`,
    type: 'account_onboarding',
  });

  res.json({ url: link.url });
});

// POST /api/webhooks/stripe — raw body required, register BEFORE express.json()
router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    // constructEvent is SYNC — not async
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).json({ error: `Webhook verification failed: ${err.message}` });
  }

  if (event.type === 'account.updated') {
    const account = event.data.object;
    if (account.charges_enabled && account.payouts_enabled) {
      await db.update(drivers)
        .set({ payoutStatus: 'active' })
        .where(eq(drivers.stripeAccountId, account.id));
    }
  }

  res.json({ received: true });
});

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

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

```
// server/index.js — critical middleware order
const express = require('express');
const path = require('path');

const payoutsRouter = require('./routes/payouts'); // contains webhook route
const ridesRouter = require('./routes/rides');
const trackingRouter = require('./routes/tracking');

const app = express();

// IMPORTANT: Register webhook route BEFORE express.json()
// Stripe needs the raw request body for signature verification
app.use('/api', payoutsRouter);

// Then add JSON parsing for all other routes
app.use(express.json());
app.use('/api', ridesRouter);
app.use('/api', trackingRouter);

// Serve static frontend
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});

// Bind to 0.0.0.0 — required for Replit
app.listen(5000, '0.0.0.0', () => console.log('Ride-hailing server running on port 5000'));

// After deploying to Reserved VM:
// 1. Stripe Dashboard → Developers → Webhooks → Add endpoint
// 2. URL: https://your-repl.replit.app/api/webhooks/stripe
// 3. Select events: account.updated, transfer.created
// 4. Copy Signing Secret → Replit Secrets → STRIPE_WEBHOOK_SECRET
```

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

File: `server/routes/rides.js`

```javascript
const express = require('express');
const { rides, drivers, users, rideTracking, ratings } = require('../../shared/schema');
const { eq, and, desc } = require('drizzle-orm');
const { db } = require('../db');

const router = express.Router();

// POST /api/rides/:id/accept — driver accepts a requested ride
router.post('/rides/:id/accept', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const [user] = await db.select().from(users).where(eq(users.userId, userId));
  const [driver] = await db.select().from(drivers).where(eq(drivers.userId, user.id));
  if (!driver) return res.status(403).json({ error: 'Driver profile required' });

  const [ride] = await db.select().from(rides)
    .where(and(eq(rides.id, parseInt(req.params.id)), eq(rides.status, 'requested')));
  if (!ride) return res.status(404).json({ error: 'Ride not found or already accepted' });

  const [updated] = await db.update(rides)
    .set({ driverId: user.id, status: 'matched' })
    .where(eq(rides.id, ride.id))
    .returning();

  res.json(updated);
});

// PATCH /api/rides/:id/status — advance through ride lifecycle
router.patch('/rides/:id/status', async (req, res) => {
  const { status } = req.body;
  const allowed = ['driver_en_route', 'in_progress', 'completed', 'cancelled'];
  if (!allowed.includes(status)) return res.status(400).json({ error: 'Invalid status' });

  const updates = { status };
  if (status === 'in_progress') updates.startedAt = new Date();
  if (status === 'completed') updates.completedAt = new Date();

  const [updated] = await db.update(rides).set(updates)
    .where(eq(rides.id, parseInt(req.params.id))).returning();
  res.json(updated);
});

// POST /api/rides/:id/rate — mutual post-ride ratings
router.post('/rides/:id/rate', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });
  const { driverRating, riderRating, driverComment, riderComment } = req.body;
  const [rating] = await db.insert(ratings).values({
    rideId: parseInt(req.params.id),
    driverRating: driverRating || null,
    riderRating: riderRating || null,
    driverComment: driverComment || null,
    riderComment: riderComment || null,
  }).returning();
  res.status(201).json(rating);
});

module.exports = router;
```

## Common mistakes

- **Using the /stripe Replit command instead of installing the SDK manually** — The /stripe command provisions a basic single-vendor checkout and does not support Stripe Connect transfer flows needed to pay individual drivers after each ride. Fix: 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).
- **Deploying on Autoscale instead of Reserved VM** — GPS tracking depends on 5-second polling from both driver and rider. Autoscale scales to zero during idle periods — a cold start takes 2-10 seconds, which breaks the real-time tracking experience and can cause missed Stripe webhook events. Fix: 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.
- **Registering the Stripe webhook route after express.json() middleware** — express.json() parses req.body into a JavaScript object. Stripe's constructEvent() needs the raw Buffer to verify the signature. Registering the webhook route after json() means every signature verification fails with a 400 error. Fix: Register app.use('/api', payoutsRouter) before app.use(express.json()). Use express.raw({ type: 'application/json' }) only on the webhook route itself.
- **Hardcoding API keys instead of using Replit Secrets** — ORS_API_KEY and STRIPE_SECRET_KEY hardcoded in source code are visible to anyone who can view the Repl or the GitHub repo it syncs to. Fix: 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.

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

---

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