DEV Community

Cover image for How to Build a Grocery Delivery App with React Native + Expo (2026)
Famitha M A
Famitha M A

Posted on

How to Build a Grocery Delivery App with React Native + Expo (2026)

How to Build a Grocery Delivery App with React Native + Expo (2026)

If you've ever tried to build an Instacart-style app from scratch, you know the pain: three apps to ship (customer, driver, admin), a real-time tracking layer, payments, dispatch, and the ever-fun "handle a driver going offline mid-delivery."

Here's a practical stack and a build sequence that keeps the moving parts to a minimum.

The three-app reality

A grocery delivery product is three apps sharing one backend:

  • Customer app — browse, cart, checkout, track
  • Driver app — accept orders, navigate, capture proof of delivery
  • Admin dashboard — inventory, dispatch, disputes

Combine them into one and you'll spend the next quarter untangling permission logic. Keep them separate.

The stack that converges

  • Mobile: React Native + Expo (one codebase, iOS + Android, OTA updates)
  • Backend: Supabase (Postgres, auth, realtime, RLS)
  • Payments: Stripe Payment Intents + Stripe Connect for driver payouts
  • Maps: Google Maps SDK + Directions API + Distance Matrix
  • Push: Expo Push → Twilio SMS for critical alerts
  • Realtime: Supabase realtime channels (no custom WebSocket server)

Data model — the parts that matter

-- The critical detail: capture price at purchase time
create table order_items (
  id uuid primary key default gen_random_uuid(),
  order_id uuid references orders(id),
  product_id uuid references products(id),
  quantity int not null,
  price_at_purchase int not null,  -- never join back to products.price_cents
  substitution_preference text check (
    substitution_preference in ('allow','contact','refund')
  )
);

-- Append-only audit log = free source of truth for status timeline
create table order_events (
  id uuid primary key default gen_random_uuid(),
  order_id uuid references orders(id),
  event_type text not null,
  metadata jsonb,
  created_at timestamptz default now()
);
Enter fullscreen mode Exit fullscreen mode

Two invariants worth burning into your brain:

  1. Never resolve order_items.price by joining back to products. Prices change; receipts must not.
  2. Never trust the client's clock for delivered_at. Use now() in Postgres. You will thank yourself the first time a dispute goes to your inbox.

Real-time tracking without pain

Driver side: capture location every 5–15 seconds, back off when stationary.

import * as Location from 'expo-location';
import { supabase } from './supabase';

await Location.startLocationUpdatesAsync('driver-location', {
  accuracy: Location.Accuracy.Balanced,
  timeInterval: 10_000,
  distanceInterval: 30,
  foregroundService: {
    notificationTitle: 'Delivery in progress',
    notificationBody: 'Sharing your location with the customer',
  },
});
Enter fullscreen mode Exit fullscreen mode

Customer side: subscribe to the driver row.

useEffect(() => {
  const channel = supabase
    .channel(`driver:${driverId}`)
    .on('postgres_changes', {
      event: 'UPDATE',
      schema: 'public',
      table: 'drivers',
      filter: `id=eq.${driverId}`,
    }, ({ new: driver }) => {
      setDriverPosition({ lat: driver.current_lat, lng: driver.current_lng });
    })
    .subscribe();
  return () => { channel.unsubscribe(); };
}, [driverId]);
Enter fullscreen mode Exit fullscreen mode

Refresh ETA every 30 seconds via Distance Matrix — not on every location ping, or you'll blow through your rate limit before lunch.

Payments — the substitution problem

Stripe Payment Intents flow:

  1. First order: create a Stripe Customer, attach a SetupIntent to save the card off-session.
  2. Checkout: create a PaymentIntent with confirm: false.
  3. Only confirm after the order is packed. Never authorize before you know items are available.

The substitution policy question you must answer on day one: pre-authorize subtotal × 1.15 to cover swaps (Instacart's approach) or refund the diff (better UX, more work). Either works; pick one and communicate it at checkout.

The screens

Nine screens ship a customer app: Onboarding + Address, Home, Category Listing, Product Detail, Cart, Checkout, Order Tracking, Order History, Profile.

Writing these by hand from scratch is a 6–8 week grind. If you want to skip the JSX, prompts to an AI mobile app builder generate them in an afternoon and export clean React Native you can extend by hand. I've been using RapidNative for this and shipping the boring UI in a day so I can spend the rest of the sprint on dispatch logic.

The subtle bugs to avoid

  • Client-side timestamps. Always now() in the DB.
  • Trusting the driver's app to update order status. Use Postgres triggers on order_events to update orders.status atomically.
  • Reading inventory from products.inventory_count at checkout. Race conditions. Use a serializable transaction or SELECT ... FOR UPDATE.
  • Rendering the map on every location ping. Debounce marker updates.

Wrap

React Native + Expo + Supabase + Stripe + Google Maps is boring, well-documented, and consolidated for a reason. The wins in this space come from operational density and freshness, not from the tech stack. Ship the MVP, get it in front of ten customers, iterate.

If you want the whole architecture in one place (data model, screen list, prompt sequence), the full walkthrough is on our blog.

Posting instructions for Dev.to:

  • Best time: Tuesday–Thursday, 8–10 AM ET
  • Community: Also cross-post to #reactnative and #tutorial Dev.to tags
  • Engagement: Reply to every comment in the first 24 hours; Dev.to's algorithm weights early engagement
  • Cover image: Use the Unsplash URL in frontmatter or upload a screenshot of the finished app
  • After posting: Share in r/reactnative and r/expo with the Dev.to link (Reddit tolerates Dev.to links better than direct-to-marketing links)

Top comments (0)