# How to Build a Food Delivery Backend with Lovable

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

## TL;DR

Build a multi-role food delivery backend in Lovable with a customer ordering interface, a restaurant management dashboard, and a driver delivery view — all connected by a Supabase Edge Function state machine that enforces the order lifecycle and broadcasts realtime status updates to every party.

## Before you start

- Lovable Pro account (multi-role logic and Edge Functions require significant generation credits)
- Supabase project created at supabase.com — free tier works for development
- Stripe account with test keys — add to Cloud tab → Secrets
- A clear decision on your role system: use Supabase Auth metadata or a profiles table with a role column
- Sample restaurant and menu data to seed for initial testing

## Step-by-step guide

### 1. Define the multi-role schema with order lifecycle

The schema must represent all three roles and the order lifecycle. The roles (customer, restaurant, driver) are stored in a profiles table that extends auth.users. RLS policies use role-based checks.

```
Create a food delivery app with Supabase. Set up these tables:

- profiles: id (references auth.users), role (customer|restaurant|driver), display_name, avatar_url, phone
- restaurants: id (references profiles), name, description, cuisine_type, address, city, is_open (bool), delivery_fee (numeric), min_order (numeric), image_url, created_at
- menu_categories: id, restaurant_id (references restaurants), name, position (int)
- menu_items: id, category_id (references menu_categories), restaurant_id, name, description, price (numeric), image_url, is_available (bool default true), created_at
- menu_item_modifiers: id, menu_item_id, name, type (single|multiple), required (bool), options (jsonb: [{label, price_add}])
- customer_addresses: id, customer_id (references profiles), label (Home|Work|Other), street, city, state, zip, is_default (bool)
- orders: id, customer_id, restaurant_id, driver_id (nullable), status (placed|confirmed|preparing|ready|picked_up|delivered|cancelled), items (jsonb snapshot), subtotal, delivery_fee, tip, total, stripe_payment_intent_id, delivery_address (jsonb), special_instructions, estimated_delivery_at, created_at, updated_at
- driver_locations: id, driver_id (unique — one per driver), lat (numeric), lng (numeric), updated_at

RLS:
- profiles: users can read/update their own profile; restaurants and drivers are readable by all authenticated users
- restaurants: anyone can read open restaurants; restaurant owners can update their own row
- menu_items/categories: anyone can read; restaurant owner can CRUD for their restaurant
- orders: customers see their own orders; restaurants see orders for their restaurant_id; drivers see orders assigned to them or with status=ready
- driver_locations: drivers can update their own location; customers with an active order can read the driver_location for their assigned driver
```

> Pro tip: Ask Lovable to create a Supabase check constraint on the orders status column that only allows valid transitions using a PostgreSQL function: CREATE FUNCTION valid_order_transition(old_status text, new_status text) RETURNS bool and reference it in a BEFORE UPDATE trigger. This adds database-level enforcement on top of the Edge Function.

**Expected result:** All eight tables are created with correct foreign keys, RLS policies, and TypeScript types. The profiles table has the role column and seeded rows for a test restaurant, customer, and driver.

### 2. Build the restaurant menu management dashboard

Restaurant owners need a dashboard to manage their menu: add categories, add items within categories, toggle item availability in real time, and set modifier groups (e.g., add-ons, sizes).

```
Build a restaurant dashboard at src/pages/RestaurantDashboard.tsx.

Requirements:
- Sidebar navigation with tabs: Orders, Menu, Settings.
- Menu tab:
  - List menu_categories for this restaurant sorted by position. Each is a collapsible AccordionItem.
  - Inside each category, list menu_items as rows: image thumbnail, name, price, an available/unavailable Toggle switch.
  - Toggling the switch calls supabase.from('menu_items').update({ is_available }).eq('id', itemId).
  - 'Add Category' Button opens a Dialog with a name Input and saves to menu_categories.
  - 'Add Item' Button inside each category opens a Sheet with fields: name (Input), description (Textarea), price (Input number), image upload (file Input to Supabase Storage bucket 'menu'), is_available (Switch).
  - Drag-to-reorder categories using position integer (show drag handle icon, update position on drop).
- Orders tab:
  - Four columns (kanban-style): Placed, Confirmed, Preparing, Ready.
  - Each order is a Card showing: order ID, customer name, item count, total, time elapsed since placed.
  - Buttons on each card: 'Confirm', 'Mark Preparing', 'Mark Ready' — each calls the order-transition Edge Function.
  - Subscribe to Realtime inserts on orders WHERE restaurant_id = this restaurant to show new orders instantly.
  - New order cards slide in with a brief highlight animation using Tailwind transition.
```

> Pro tip: Add a browser Notification API call when a new order arrives in the Realtime subscription. Ask Lovable to request notification permission on dashboard load and send a native browser notification with the order summary when status changes to 'placed'. This works even when the tab is in the background.

**Expected result:** Restaurant owners can toggle item availability and it reflects immediately on the customer-facing menu. New orders appear in the Placed column in real time without page refresh.

### 3. Build the order lifecycle Edge Function

The state machine Edge Function is the heart of the system. It validates transitions, updates the order, and notifies all parties via Realtime broadcast.

```
Create a Supabase Edge Function at supabase/functions/order-transition/index.ts.

Receives: { order_id: string, new_status: string, metadata?: object } in body.
Requires authentication.

Valid transitions map:
const TRANSITIONS: Record<string, string[]> = {
  placed: ['confirmed', 'cancelled'],
  confirmed: ['preparing', 'cancelled'],
  preparing: ['ready'],
  ready: ['picked_up'],
  picked_up: ['delivered'],
  delivered: [],
  cancelled: [],
}

Logic:
1. Fetch the order by order_id using service role key.
2. Get the caller's profile to verify they are allowed to make this transition:
   - Restaurant can transition: placed→confirmed, confirmed→preparing, preparing→ready
   - Driver can transition: ready→picked_up, picked_up→delivered (only if order.driver_id = caller's id)
   - Customer can transition: placed→cancelled, confirmed→cancelled
   - Admin can do any transition
3. If transition is invalid (not in TRANSITIONS[current_status]), return 400 error.
4. If caller role doesn't match allowed transitions, return 403.
5. Build update object: { status: new_status, updated_at: new Date().toISOString() }
   - If new_status === 'confirmed': set estimated_delivery_at = NOW() + 40 minutes
   - If new_status === 'picked_up': set driver_id = caller's id (for drivers accepting from ready queue)
6. Update orders row.
7. Broadcast to Supabase Realtime channel 'order-{order_id}': { type: 'status_change', status: new_status, timestamp }
8. Return { success: true, new_status }
```

**Expected result:** Calling the Edge Function with a valid transition updates the order status and broadcasts to the Realtime channel. Invalid transitions return a 400 with a descriptive error. Unauthorized role transitions return 403.

### 4. Build the customer ordering flow and realtime tracker

Customers browse restaurants, add items to a cart with modifiers, check out with Stripe, then watch their order status update live on a tracking screen.

```
import { useEffect, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'

const STATUS_STEPS = ['placed', 'confirmed', 'preparing', 'ready', 'picked_up', 'delivered'] as const
type OrderStatus = typeof STATUS_STEPS[number]

const STATUS_LABELS: Record<OrderStatus, string> = {
  placed: 'Order Placed',
  confirmed: 'Restaurant Confirmed',
  preparing: 'Preparing Your Food',
  ready: 'Ready for Pickup',
  picked_up: 'Driver Picked Up',
  delivered: 'Delivered',
}

export function OrderTracker({ orderId }: { orderId: string }) {
  const [status, setStatus] = useState<OrderStatus>('placed')
  const [estimatedAt, setEstimatedAt] = useState<string | null>(null)
  const [driverLocation, setDriverLocation] = useState<{ lat: number; lng: number } | null>(null)

  useEffect(() => {
    supabase.from('orders').select('status, estimated_delivery_at, driver_id').eq('id', orderId).single()
      .then(({ data }) => {
        if (data) {
          setStatus(data.status as OrderStatus)
          setEstimatedAt(data.estimated_delivery_at)
        }
      })

    const orderChannel = supabase
      .channel(`order-${orderId}`)
      .on('broadcast', { event: 'status_change' }, (payload) => {
        setStatus(payload.payload.status as OrderStatus)
      })
      .subscribe()

    const locationChannel = supabase
      .channel(`driver-location-${orderId}`)
      .on('postgres_changes', {
        event: 'UPDATE',
        schema: 'public',
        table: 'driver_locations',
      }, (payload) => {
        setDriverLocation({ lat: payload.new.lat, lng: payload.new.lng })
      })
      .subscribe()

    return () => {
      supabase.removeChannel(orderChannel)
      supabase.removeChannel(locationChannel)
    }
  }, [orderId])

  const stepIndex = STATUS_STEPS.indexOf(status)
  const progress = Math.round((stepIndex / (STATUS_STEPS.length - 1)) * 100)

  return (
    <Card className="w-full max-w-lg">
      <CardHeader>
        <CardTitle className="flex justify-between items-center">
          <span>Order #{orderId.slice(-6).toUpperCase()}</span>
          <Badge>{STATUS_LABELS[status]}</Badge>
        </CardTitle>
        {estimatedAt && (
          <p className="text-sm text-muted-foreground">
            Estimated delivery: {new Date(estimatedAt).toLocaleTimeString()}
          </p>
        )}
      </CardHeader>
      <CardContent className="space-y-6">
        <Progress value={progress} className="h-2" />
        <div className="space-y-3">
          {STATUS_STEPS.map((step, i) => (
            <div key={step} className={`flex items-center gap-3 ${i <= stepIndex ? 'text-foreground' : 'text-muted-foreground'}`}>
              <div className={`w-2 h-2 rounded-full ${i < stepIndex ? 'bg-green-500' : i === stepIndex ? 'bg-primary animate-pulse' : 'bg-muted'}`} />
              <span className="text-sm">{STATUS_LABELS[step]}</span>
            </div>
          ))}
        </div>
        {driverLocation && (
          <p className="text-sm text-muted-foreground">Driver location updated {new Date().toLocaleTimeString()}</p>
        )}
      </CardContent>
    </Card>
  )
}
```

**Expected result:** Customers can track their order status in real time. The progress bar advances as the restaurant and driver update the order. The estimated delivery time appears once the restaurant confirms.

### 5. Build the driver delivery queue and location update

Drivers see orders with status 'ready' in their queue, can accept one, and then progress it through pickup and delivery. Their location is updated every 30 seconds to let customers track them.

```
Build a driver dashboard at src/pages/DriverDashboard.tsx.

Requirements:
- Two tabs: Available Orders, My Active Delivery.

Available Orders tab:
- Fetch orders WHERE status = 'ready' AND driver_id IS NULL.
- Each is a Card showing: restaurant name, delivery address, payout estimate (delivery_fee * 0.8), distance (calculate from driver's last known location if available).
- 'Accept' Button calls order-transition Edge Function with new_status='picked_up' (which sets driver_id to this driver).
- Realtime subscription to orders WHERE status = 'ready' for live queue updates.

My Active Delivery tab:
- Fetch the one order where driver_id = auth.uid() AND status IN ('picked_up').
- Show order details, customer name, delivery address on a Card.
- Large 'Mark Delivered' Button that calls order-transition Edge Function with new_status='delivered'.
- Every 30 seconds, upsert driver_locations: { driver_id: auth.uid(), lat, lng, updated_at } using browser Geolocation API.
  - Use navigator.geolocation.watchPosition to get the current position.
  - Only upsert if the position changed by more than 0.001 degrees to avoid unnecessary writes.

Add a location permission request banner if Geolocation is not yet granted.
```

> Pro tip: Use navigator.geolocation.watchPosition instead of setInterval + getCurrentPosition. watchPosition fires automatically when the device detects movement, which is more battery-efficient on mobile and provides more accurate tracking.

**Expected result:** Drivers see available orders in real time. Accepting an order claims it and moves it to the active delivery tab. The customer's tracker shows location updates as the driver moves.

## Complete code example

File: `src/hooks/useOrderLifecycle.ts`

```typescript
import { useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { useToast } from '@/hooks/use-toast'

export type OrderStatus = 'placed' | 'confirmed' | 'preparing' | 'ready' | 'picked_up' | 'delivered' | 'cancelled'

const NEXT_STATUS: Partial<Record<OrderStatus, OrderStatus>> = {
  placed: 'confirmed',
  confirmed: 'preparing',
  preparing: 'ready',
  ready: 'picked_up',
  picked_up: 'delivered',
}

const STATUS_ACTION_LABEL: Partial<Record<OrderStatus, string>> = {
  placed: 'Confirm Order',
  confirmed: 'Start Preparing',
  preparing: 'Mark Ready',
  ready: 'Mark Picked Up',
  picked_up: 'Mark Delivered',
}

export function useOrderLifecycle(orderId: string, currentStatus: OrderStatus) {
  const [loading, setLoading] = useState(false)
  const { toast } = useToast()

  const nextStatus = NEXT_STATUS[currentStatus]
  const actionLabel = STATUS_ACTION_LABEL[currentStatus]

  const advance = async () => {
    if (!nextStatus) return
    setLoading(true)
    const { data, error } = await supabase.functions.invoke('order-transition', {
      body: { order_id: orderId, new_status: nextStatus },
    })
    setLoading(false)

    if (error || !data?.success) {
      toast({
        title: 'Update failed',
        description: error?.message ?? 'Could not update order status. Please try again.',
        variant: 'destructive',
      })
      return false
    }

    toast({
      title: 'Order updated',
      description: `Status changed to ${nextStatus.replace('_', ' ')}`,
    })
    return true
  }

  const cancel = async () => {
    setLoading(true)
    const { data, error } = await supabase.functions.invoke('order-transition', {
      body: { order_id: orderId, new_status: 'cancelled' },
    })
    setLoading(false)
    if (error || !data?.success) {
      toast({ title: 'Cancel failed', description: error?.message, variant: 'destructive' })
      return false
    }
    return true
  }

  return { advance, cancel, loading, nextStatus, actionLabel, canAdvance: !!nextStatus }
}
```

## Common mistakes

- **Letting customers directly update order status via the Supabase client** — Without the Edge Function state machine, a customer with the Supabase client can call UPDATE orders SET status = 'delivered' on any order, bypassing the validation entirely. Fix: Add an RLS UPDATE policy that restricts the status column: customers can only update their own orders and only to 'cancelled' (and only from 'placed' or 'confirmed'). All other transitions must go through the order-transition Edge Function which uses the service role key.
- **Storing menu items as a JSON blob directly on the order instead of a snapshot** — If you store only item IDs in the order and the restaurant later changes the item price or deletes the item, the historical order display breaks. Fix: At order creation time, capture a snapshot of each ordered item's name, price, and modifiers into the orders.items jsonb column. This preserves the exact state of the menu at the time of purchase.
- **Polling for order status updates instead of using Realtime** — Polling with setInterval creates N simultaneous requests per open tab and adds latency to status changes. Fix: Subscribe to the Realtime broadcast channel for each order (order-{id}) and the postgres_changes channel for driver_locations. Cancel all subscriptions in the useEffect cleanup to prevent accumulation.
- **Not handling driver location permission denial gracefully** — Calling navigator.geolocation.watchPosition without checking permission first throws an error on browsers where location is blocked. This can crash the driver dashboard. Fix: Use navigator.permissions.query({ name: 'geolocation' }) before calling watchPosition. If state is 'denied', show a persistent Banner explaining that location sharing is required for the delivery queue and provide instructions to enable it.

## Best practices

- Snapshot order item data (name, price, modifiers) into a jsonb column at placement time. Never rely on live joins to menu_items for displaying historical orders.
- Enforce status transition logic at both the Edge Function level (role and sequence check) and the database level (CHECK constraint or trigger) for defense in depth.
- Use Supabase Realtime Broadcast for order status changes rather than postgres_changes on the orders table. Broadcast is lighter weight and avoids exposing the full order row diff to subscribers.
- Update driver_locations with an UPSERT (ON CONFLICT (driver_id) DO UPDATE) so there is only one row per driver, not a growing history. If you need location history, write to a separate driver_location_history table.
- Index orders on (restaurant_id, status) and (driver_id, status) separately. Restaurant and driver dashboards filter on these column combinations constantly.
- Show order age prominently in the restaurant dashboard (minutes since placed). Restaurants need to prioritize old orders. Highlight orders over 10 minutes with an amber Badge and over 20 minutes with a red Badge.
- Add estimated_delivery_at when confirming an order and update it when the driver picks up. Use this field to calculate expected arrival on the customer tracker rather than generic time labels.

## Frequently asked questions

### How do I implement role-based routing so each role sees the right dashboard?

On login, fetch the user's profile row and check the role column. Store the role in a React context or Zustand store. Use a wrapper component around your routes that reads the role and renders the correct dashboard component. Ask Lovable to create a RoleGuard component that redirects to /login if unauthenticated and to /unauthorized if the user's role does not match the required role for that route.

### Can a driver reject an order after accepting it?

Allowing cancellation after acceptance is a business decision. Technically, you can add a transition from picked_up back to ready (clearing driver_id) if it happens quickly — add this to the TRANSITIONS map in the Edge Function and restrict it to drivers only. Add a time limit: cancellation is only allowed within 2 minutes of acceptance by checking (updated_at - NOW()) < 2 minutes in the Edge Function.

### How do I handle cash-on-delivery orders?

Add a payment_method column to orders (card|cash). For cash orders, skip the Stripe checkout and create the order with status 'confirmed' directly. The order lifecycle is identical, but there is no PaymentIntent or Stripe webhook. Mark the order as paid when the driver delivers by updating a paid boolean column in the same transition that sets status to 'delivered'.

### How do I show restaurants on a map for the browse page?

Add lat and lng numeric columns to the restaurants table. On the browse page, fetch active restaurants with their coordinates and render them on a map. Lovable works well with react-leaflet (open source, no API key needed for basic maps). Ask Lovable to add a toggle between grid view and map view on the browse page.

### How do menu modifiers work in the cart?

When a customer adds an item with modifiers, show a Dialog with the modifier options before adding to cart. Build the selected option list into the cart item's local state as { modifier_name, option_label, price_add }[]. When creating the order, include these selections in the items jsonb snapshot. The price calculation in the frontend sums item base price plus all selected modifier price_add values.

### Can multiple restaurants use the same Lovable app?

Yes. The schema supports multiple restaurants via the restaurants table with each restaurant having its own profile row. Restaurant owners see only their own data through RLS. The browse page shows all active restaurants. Scale-wise, Supabase free tier supports hundreds of restaurants. RLS keeps the data correctly isolated without any application-level filtering.

### How do I send order confirmation emails?

In the order-transition Edge Function, when new_status changes to 'confirmed', add a call to your email provider (Resend works well). Fetch the customer's email from auth.users, the restaurant name, and the order items from the orders jsonb snapshot. Send a confirmation email with order number, items, total, and estimated delivery time. Store your RESEND_API_KEY in Cloud tab → Secrets.

### Is there help available for production delivery platform builds?

RapidDev builds production-grade Lovable apps including multi-role platforms with complex state machines, Stripe Connect driver payouts, and real-time tracking. Reach out if your food delivery app needs expert development support.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/food-delivery-backend
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/food-delivery-backend
