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()
);
Two invariants worth burning into your brain:
- Never resolve
order_items.priceby joining back toproducts. Prices change; receipts must not. - Never trust the client's clock for
delivered_at. Usenow()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',
},
});
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]);
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:
- First order: create a Stripe Customer, attach a
SetupIntentto save the card off-session. - Checkout: create a
PaymentIntentwithconfirm: false. - 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_eventsto updateorders.statusatomically. -
Reading inventory from
products.inventory_countat checkout. Race conditions. Use a serializable transaction orSELECT ... 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
#reactnativeand#tutorialDev.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/reactnativeandr/expowith the Dev.to link (Reddit tolerates Dev.to links better than direct-to-marketing links)
Top comments (0)