TL;DR
The one-paragraph version before you dive in.
This is the hardest app in this prompt library. A three-sided food delivery backend means restaurants manage menus, customers order, and drivers fulfill — with Stripe Connect payouts, real-time order status across all three roles, and multi-role RLS that is genuinely the hardest pattern Lovable handles. No exact food-delivery Lovable build is documented in 2026 research; credit estimates are extrapolated. Budget 250–400 credits and plan to finish in Cursor.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores restaurants, menu_items, orders, order_items, drivers, and profiles with role enum. The starter prompt creates the migration with the has_role() SECURITY DEFINER function and all six tables.
- 1Click the + button next to Preview to open the Cloud panel
- 2Click Database — leave the Table Editor empty; the starter prompt creates all tables
- 3After the migration runs, verify the has_role() function exists in Cloud → Database → Functions tab
Auth
Email/password for all three roles. Role assignment goes in app_metadata (not user_metadata — user_metadata is user-editable and must never hold roles).
- 1Cloud tab → Users & Auth → confirm Email sign-in is enabled
- 2After a user registers, set their role via Cloud → Users & Auth → find the user → edit app_metadata to {"role": "customer"} or "restaurant_owner" or "driver"
- 3Note: in production, role assignment happens server-side after identity verification, not via the UI
Storage
Restaurant logos and menu item photos. Two public buckets: restaurant-logos and menu-photos.
- 1Cloud tab → Storage → Create bucket 'restaurant-logos', toggle Public ON
- 2Create bucket 'menu-photos', toggle Public ON
- 3The starter prompt references both bucket names — do not rename them
Edge Functions
Stripe checkout with destination charge, raw-body webhook, dispatch logic, and nearby-restaurant query all run server-side where secrets are safe.
- 1Cloud tab → Edge Functions — leave empty; functions deploy from supabase/functions/ automatically
- 2After the starter prompt runs, verify checkout, stripe-webhook, dispatch-order, get-nearby-restaurants appear here
Secrets (Cloud tab → Secrets)
STRIPE_SECRET_KEYPurpose: Creates Payment Intents with destination charges and application fees in the checkout edge function
Where to get it: https://dashboard.stripe.com/apikeys — copy the secret key starting with sk_live_ (or sk_test_ for testing). Use test key during build.
STRIPE_WEBHOOK_SECRETPurpose: Verifies Stripe webhook signatures in the stripe-webhook edge function using constructEventAsync
Where to get it: https://dashboard.stripe.com/webhooks — create a webhook endpoint pointing at your deployed URL + /functions/v1/stripe-webhook, select event checkout.session.completed — copy the signing secret starting with whsec_
STRIPE_CONNECT_CLIENT_IDPurpose: Redirects restaurants to Stripe Express onboarding to connect their payout account
Where to get it: https://dashboard.stripe.com/settings/connect — Platform settings → Live Client ID (or Test Client ID). Required for restaurant Stripe Connect onboarding.
GOOGLE_MAPS_API_KEYPurpose: Geocodes delivery addresses and computes distance for the get-nearby-restaurants function
Where to get it: https://console.cloud.google.com → APIs & Services → Credentials → Create API Key → restrict to Geocoding API and Maps JavaScript API
RESEND_API_KEYPurpose: Sends order confirmation emails to customers after successful payment
Where to get it: https://resend.com/api-keys — free account, Create API Key with Sending access
Preflight checklist
- You're on Pro $25/mo with top-ups ready — this is the most credit-intensive app in the tier; expect $30+ in top-ups on top of the $25 base
- You have a Stripe account with Connect enabled (dashboard.stripe.com/settings/connect) — Connect requires account approval which can take 1–2 business days
- You understand this is a cousin-substitution category: no documented end-to-end Lovable food-delivery build exists in 2026 research; credit estimates are extrapolated from marketplace + realtime + payment integration patterns and may vary significantly
- Plan to export to GitHub and finish the multi-role RLS audit in Cursor before any beta launch — Lovable community satisfaction is documented as lowest for multi-user platforms
The starter prompt — paste this first
Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.
Build a three-sided food delivery backend. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.
IMPORTANT: This app has three user roles — customer, restaurant_owner, and driver. All role checks MUST go through a SECURITY DEFINER plpgsql function. Never check role with inline SQL in RLS policies — it causes infinite recursion on the profiles table.
## Database schema (migration: supabase/migrations/0001_food_delivery_schema.sql)
First, enable extensions:
CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE;
CREATE EXTENSION IF NOT EXISTS cube;
Create the has_role() function BEFORE any tables:
CREATE OR REPLACE FUNCTION has_role(role_name text)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
STABLE
AS $$
BEGIN
RETURN (auth.jwt() -> 'app_metadata' ->> 'role') = role_name;
END;
$$;
Create six tables:
1. `profiles` — id (uuid references auth.users.id on delete cascade, pk), role (text check (role in ('customer','restaurant_owner','driver','admin')) not null), full_name (text), phone (text), created_at (timestamptz default now()).
RLS: enable. FOR SELECT: per-user (id = auth.uid()). FOR INSERT: WITH CHECK (id = auth.uid()) — role is set by admin via app_metadata, not here. FOR UPDATE: USING (id = auth.uid()) — cannot update role column (enforce this at app layer).
2. `restaurants` — id (uuid pk default gen_random_uuid()), owner_id (uuid references auth.users.id not null), name (text not null), slug (text unique not null), address (text), lat (float), lng (float), stripe_account_id (text), accepting_orders (bool default false), logo_path (text), created_at (timestamptz default now()).
RLS: enable. FOR SELECT TO anon, authenticated USING (accepting_orders = true). FOR INSERT TO authenticated WITH CHECK (has_role('restaurant_owner') AND owner_id = auth.uid()). FOR UPDATE TO authenticated USING (has_role('restaurant_owner') AND owner_id = auth.uid()). Admin: USING (has_role('admin')).
3. `menu_items` — id (uuid pk default gen_random_uuid()), restaurant_id (uuid references restaurants.id not null), name (text not null), description (text), price_cents (int not null), photo_path (text), available (bool default true), category (text), created_at (timestamptz default now()).
RLS: enable. FOR SELECT TO anon, authenticated USING (available = true). Restaurant owner write: WITH CHECK (EXISTS (SELECT 1 FROM restaurants r WHERE r.id = menu_items.restaurant_id AND r.owner_id = auth.uid())).
4. `orders` — id (uuid pk default gen_random_uuid()), customer_id (uuid references auth.users.id not null), restaurant_id (uuid references restaurants.id not null), driver_id (uuid references auth.users.id nullable), status (text not null default 'placed' check (status in ('placed','preparing','ready','en_route','delivered','cancelled'))), subtotal_cents (int not null), fee_cents (int not null default 0), total_cents (int not null), delivery_address (text not null), delivery_lat (float), delivery_lng (float), stripe_payment_intent_id (text unique), placed_at (timestamptz default now()).
RLS: enable. Customer reads own: FOR SELECT TO authenticated USING (customer_id = auth.uid()). Restaurant reads its own orders: FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM restaurants r WHERE r.id = orders.restaurant_id AND r.owner_id = auth.uid())). Driver reads assigned or ready-unassigned: FOR SELECT TO authenticated USING (driver_id = auth.uid() OR (status = 'ready' AND driver_id IS NULL AND has_role('driver'))). Admin: USING (has_role('admin')).
No direct INSERT for customers — orders created via checkout edge function (service-role).
5. `order_items` — id (uuid pk default gen_random_uuid()), order_id (uuid references orders.id not null), menu_item_id (uuid references menu_items.id not null), qty (int not null check (qty > 0)), unit_price_cents (int not null).
RLS: enable. Inherit via order — SELECT USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.customer_id = auth.uid() OR EXISTS (SELECT 1 FROM restaurants r WHERE r.id = o.restaurant_id AND r.owner_id = auth.uid()) OR o.driver_id = auth.uid()))).
6. `drivers` — id (uuid references auth.users.id on delete cascade, pk), vehicle_type (text), online (bool default false), current_lat (float), current_lng (float), last_ping (timestamptz default now()).
RLS: enable. Driver writes own: USING (id = auth.uid()). Restaurant reads drivers assigned to their orders: FOR SELECT TO authenticated USING (EXISTS (SELECT 1 FROM orders o WHERE o.driver_id = drivers.id AND EXISTS (SELECT 1 FROM restaurants r WHERE r.id = o.restaurant_id AND r.owner_id = auth.uid()))).
## Three distinct layouts
`src/layouts/CustomerLayout.tsx` — mobile-first, bottom tab nav: Home / Orders / Cart / Profile. Clean, food-app feel. Orange-600 primary. Include CartDrawer (Sheet from bottom).
`src/layouts/RestaurantLayout.tsx` — sidebar: Orders / Menu / Settings. Desktop-first. Show 'Accepting orders' toggle at top of sidebar that calls supabase.from('restaurants').update({ accepting_orders: !current }) for the owner's restaurant.
`src/layouts/DriverLayout.tsx` — single-screen layout, mostly the active order card at top with status. No sidebar. Large map area placeholder (div with bg-gray-100 and text 'Map loads here').
All three layouts use `<RoleGuard role={required_role}>` that reads app_metadata.role from the Supabase session and redirects to / if the role doesn't match.
## Pages (add to src/App.tsx with role-based routing)
Customer routes: / (Browse.tsx — restaurant grid with earthdistance ordering), /restaurant/[slug] (RestaurantDetail.tsx — menu with add-to-cart), /cart (Cart.tsx — cart items + checkout button), /checkout (Checkout.tsx — delivery address + calls checkout edge function), /orders (OrderHistory.tsx — past orders), /orders/[id] (OrderDetail.tsx with LiveOrderTracker component).
Restaurant routes: /r/orders (OrderQueue.tsx — live list of placed/preparing orders with status update buttons), /r/menu (MenuEditor.tsx — menu item CRUD with photo upload), /r/settings (RestaurantSettings.tsx — name, address, Stripe Connect status).
Driver routes: /d/active (ActiveOrder.tsx — current assigned order with customer address + status update buttons: En Route / Delivered), /d/history (DeliveryHistory.tsx).
## Key components
`src/components/CartDrawer.tsx` — Sheet from bottom with cart items, qty controls, subtotal, checkout CTA.
`src/components/MenuItemRow.tsx` — item name, description, price, photo thumbnail, Add button.
`src/components/OrderStatusBadge.tsx` — color-coded badge: placed=yellow, preparing=orange, ready=blue, en_route=purple, delivered=green, cancelled=red.
`src/components/LiveOrderTracker.tsx` — simple step indicator showing all status stages with the current one highlighted. Subscribes to Supabase Realtime on orders filtered by the order id.
`src/components/RoleGuard.tsx` — reads auth session and app_metadata.role, shows loading spinner while checking, redirects to / if role does not match.
## Edge functions
`supabase/functions/get-nearby-restaurants/index.ts` — GET with query params lat, lng, radius_km (default 5). Uses earthdistance: SELECT *, (earth_distance(ll_to_earth(lat, lng), ll_to_earth($1, $2)) / 1000) AS dist_km FROM restaurants WHERE accepting_orders = true AND earth_distance(ll_to_earth(lat, lng), ll_to_earth($1, $2)) < $3 * 1000 ORDER BY dist_km LIMIT 20. Return { restaurants: [...] }.
`supabase/functions/checkout/index.ts` — POST JSON { restaurant_id, cart_items: [{menu_item_id, qty}], delivery_address, delivery_lat, delivery_lng, customer_id }. Steps: (1) CORS preflight. (2) Verify cart_items exist and are available. (3) Calculate subtotal. (4) Fetch restaurant.stripe_account_id. (5) Create Stripe PaymentIntent with application_fee_amount = subtotal * 0.15 (15% platform fee) and transfer_data: { destination: restaurant.stripe_account_id }. (6) Return { client_secret } to client for Stripe Elements. Do NOT create the order row here — create it in the webhook after payment confirmation.
`supabase/functions/stripe-webhook/index.ts` — CRITICAL: (1) const rawBody = await req.text(); — this MUST be the first line, before any JSON.parse. (2) CORS preflight. (3) const sig = req.headers.get('stripe-signature'); (4) const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); — use constructEventAsync, NOT constructEvent. (5) On event.type === 'payment_intent.succeeded': INSERT INTO orders (..., stripe_payment_intent_id) ON CONFLICT (stripe_payment_intent_id) DO NOTHING (idempotent). (6) INSERT INTO order_items for each cart item. (7) Send confirmation email via Resend. (8) Return 200 immediately.
`supabase/functions/dispatch-order/index.ts` — called internally when orders.status flips to 'ready'. Finds online drivers within 3km using earthdistance, picks the one with the lowest count of en_route orders, sets orders.driver_id. Return 200.
## Styling
Orange-600 primary. Rounded-2xl cards with food photography placeholder. Customer app: mobile-first with large touch targets. Restaurant app: desktop table-first. Driver app: single-screen, minimal. Use shadcn Drawer, Sheet, Badge, Card, Skeleton, Toast throughout.
Generate migration (with has_role function) first, then three layouts, then pages in role order (customer first), then edge functions.What this prompt generates
- Creates SQL migration with 6 tables, has_role() SECURITY DEFINER function, earthdistance extension, and multi-role RLS policies
- Scaffolds three distinct layouts (CustomerLayout, RestaurantLayout, DriverLayout) with RoleGuard routing
- Generates ~11 pages across three role namespaces: customer browse/cart/orders, restaurant order queue/menu editor, driver active order/history
- Creates checkout edge function with Stripe destination charge for restaurant payouts
- Creates stripe-webhook edge function with raw-body reading and constructEventAsync (the correct pattern for Deno)
Paste into: Lovable Agent Mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_food_delivery_schema.sql6 tables + has_role() SECURITY DEFINER + earthdistance + RLS policies
src/layouts/CustomerLayout.tsxMobile-first bottom-tab nav with CartDrawer
src/layouts/RestaurantLayout.tsxSidebar with accepting_orders toggle
src/layouts/DriverLayout.tsxSingle-screen map area with active order card
src/components/RoleGuard.tsxRole-based redirect from auth JWT app_metadata.role
src/pages/customer/Browse.tsxRestaurant grid with nearby ordering
src/pages/customer/RestaurantDetail.tsxMenu with add-to-cart
src/pages/customer/Cart.tsxCart items with qty controls and checkout CTA
src/pages/customer/Checkout.tsxDelivery address + Stripe Elements payment
src/pages/customer/OrderDetail.tsxLive order status with Realtime subscription
src/pages/restaurant/OrderQueue.tsxLive order list with status update buttons
src/pages/restaurant/MenuEditor.tsxMenu item CRUD with photo upload
src/pages/driver/ActiveOrder.tsxCurrent delivery with En Route / Delivered buttons
supabase/functions/get-nearby-restaurants/index.tsEarthdistance query for nearby restaurants by lat/lng
supabase/functions/checkout/index.tsStripe PaymentIntent with Connect destination charge
supabase/functions/stripe-webhook/index.tsRaw-body webhook with constructEventAsync and idempotent order insert
supabase/functions/dispatch-order/index.tsNearest-online-driver assignment via earthdistance
Routes
Customer: nearby restaurant grid
Customer: restaurant menu and cart
Customer: cart review and checkout
Customer: live order status tracker
Restaurant: live incoming order queue
Restaurant: menu item CRUD
Driver: current active delivery
Shared sign-in with role-based redirect after auth
DB Tables
restaurants| Column | Type |
|---|---|
| id | uuid primary key |
| owner_id | uuid references auth.users |
| name | text not null |
| stripe_account_id | text nullable |
| accepting_orders | bool default false |
RLS: Public read where accepting_orders=true. Restaurant owner write via has_role('restaurant_owner') AND owner_id = auth.uid().
orders| Column | Type |
|---|---|
| id | uuid primary key |
| customer_id | uuid references auth.users |
| restaurant_id | uuid references restaurants |
| driver_id | uuid nullable references auth.users |
| status | text check in ('placed','preparing','ready','en_route','delivered','cancelled') |
| stripe_payment_intent_id | text unique |
RLS: Customer reads own. Restaurant reads for own restaurant. Driver reads assigned or status='ready' AND unassigned. No client INSERT — service-role webhook only.
drivers| Column | Type |
|---|---|
| id | uuid references auth.users primary key |
| online | bool default false |
| current_lat | float |
| current_lng | float |
| last_ping | timestamptz |
RLS: Per-driver write own. Restaurant SELECT for drivers assigned to their orders only.
profiles| Column | Type |
|---|---|
| id | uuid references auth.users primary key |
| role | text check in ('customer','restaurant_owner','driver','admin') |
| full_name | text |
| phone | text |
RLS: Per-user read+write own. Role set by admin via app_metadata, not client.
menu_items| Column | Type |
|---|---|
| id | uuid primary key |
| restaurant_id | uuid references restaurants |
| name | text not null |
| price_cents | int not null |
| available | bool default true |
RLS: Public read where available. Restaurant owner write via inner join to restaurants.owner_id.
order_items| Column | Type |
|---|---|
| id | uuid primary key |
| order_id | uuid references orders |
| menu_item_id | uuid references menu_items |
| qty | int not null |
| unit_price_cents | int not null |
RLS: Inherits via order_id — customer, restaurant, and driver can each read line items for orders they can access.
Components
RoleGuardReads JWT app_metadata.role, redirects if role doesn't match
CartDrawerBottom Sheet with cart items, qty controls, subtotal, checkout CTA
LiveOrderTrackerStep indicator with Realtime subscription to orders table
OrderStatusBadgeColor-coded badge for all 6 order statuses
MenuItemRowMenu item card with photo, price, and Add to Cart button
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Replace inline role checks with has_role() SECURITY DEFINER function
Eliminates infinite-recursion RLS risk by centralizing all role checks in the SECURITY DEFINER function
Audit every RLS policy in supabase/migrations/0001_food_delivery_schema.sql for inline role checks. Any policy that does `(SELECT role FROM profiles WHERE id = auth.uid()) = 'restaurant_owner'` or similar inline SELECT will cause infinite recursion on the profiles table.
Replace ALL role checks with: has_role('restaurant_owner'), has_role('driver'), has_role('admin') — using the SECURITY DEFINER plpgsql function created at the top of the migration.
For each existing policy, check: does the USING or WITH CHECK clause contain a subquery on the profiles table? If yes, replace with the has_role() call. The has_role() function reads directly from the JWT app_metadata via auth.jwt(), which avoids any table lookup.
Also verify: if you see `auth.jwt() ->> 'role'` in any policy, that reads from the top-level JWT claim. The correct path for app_metadata is `(auth.jwt() -> 'app_metadata' ->> 'role')` — the extra -> 'app_metadata' is required.
After updating policies, open three incognito browser windows (one customer, one restaurant owner, one driver) and verify each role only sees the data it should.When to use: Paste this IMMEDIATELY after the starter prompt — this is CVE-2025-48757 territory
Add Stripe Connect restaurant onboarding
Full Stripe Connect Express onboarding so restaurants can receive destination-charge payouts
Add the Stripe Connect onboarding flow for restaurants:
1. In src/pages/restaurant/RestaurantSettings.tsx, add a 'Connect Stripe Payout Account' card. If restaurants.stripe_account_id is null, show a 'Connect Stripe' button. If set, show 'Connected: Stripe Express account' with a 'Manage' link to the Stripe Express dashboard.
2. Create supabase/functions/stripe-connect-onboard/index.ts: POST { restaurant_id }. Creates a Stripe Express account (stripe.accounts.create({ type: 'express' })), saves the account ID to restaurants.stripe_account_id, creates an AccountLink (stripe.accountLinks.create({ account: account.id, refresh_url, return_url, type: 'account_onboarding' })), returns { url } for client redirect.
3. Create supabase/functions/stripe-connect-return/index.ts: handles return from Stripe Express onboarding, verifies the restaurant's account is charges_enabled via stripe.accounts.retrieve(account_id), updates restaurants.stripe_account_id if needed, redirects to /r/settings.
4. In the checkout edge function, add a check: if restaurant.stripe_account_id is null, return 400 { error: 'Restaurant has not connected Stripe — cannot accept orders' }. Show this error in Cart.tsx before the checkout button.
Use STRIPE_SECRET_KEY and STRIPE_CONNECT_CLIENT_ID from Deno.env.get(). Include CORS headers on all function responses.When to use: Must be complete before any restaurant can accept real payments
Harden the Stripe webhook: raw body + constructEventAsync + idempotency
Correct raw-body Stripe webhook with constructEventAsync, idempotent order insert, and fast 200 response
Update supabase/functions/stripe-webhook/index.ts with the correct raw-body pattern:
The VERY FIRST line of the handler (before any other logic) must be:
const rawBody = await req.text();
DO NOT call req.json() or JSON.parse before constructEventAsync. The bytes Stripe signed are the raw body bytes — parsing them first changes the bytes and fails signature verification.
Then:
const sig = req.headers.get('stripe-signature');
const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'));
Note: constructEventAsync (async), NOT constructEvent (sync). Deno's WebCrypto is async-only.
For idempotency on payment_intent.succeeded: use INSERT INTO orders (...) ON CONFLICT (stripe_payment_intent_id) DO NOTHING. If the row already exists, still return 200 so Stripe stops retrying.
Always return 200 within 5 seconds even if downstream processing (email, dispatch) hasn't finished. Move long-running work after the 200 response using Deno's waitUntil or a separate edge function invocation.
Add CORS preflight at the top of the handler (OPTIONS → 200 with CORS headers). Test with Stripe CLI: stripe listen --forward-to [your-deployed-url]/functions/v1/stripe-webhook and trigger with stripe trigger payment_intent.succeeded.When to use: This is the most error-prone part of the entire build — paste it as a dedicated follow-up immediately after the starter; never ship to production without it
Add real-time order status across all three roles
Live order status updates pushed to all three role views via Supabase Realtime subscriptions
Enable Supabase Realtime on the orders table and wire subscriptions in all three role apps:
1. Cloud → Database → Realtime → toggle ON for the orders table.
2. In OrderDetail.tsx (customer), subscribe to orders changes filtered by the order id:
const channel = supabase.channel('order-' + orderId).on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'orders', filter: 'id=eq.' + orderId }, (payload) => { setOrder(payload.new); }).subscribe();
Clean up: return () => supabase.removeChannel(channel); in useEffect.
3. In OrderQueue.tsx (restaurant), subscribe to all orders for the restaurant:
filter: 'restaurant_id=eq.' + restaurant.id. On INSERT (new order) show a toast 'New order received!'. On UPDATE (status change) update the order in the list in-place.
4. In ActiveOrder.tsx (driver), subscribe to the driver's current assigned order:
filter: 'driver_id=eq.' + driver.id + ',status=eq.en_route'. Show the delivery address on the map placeholder div.
5. Also subscribe to the orders table for unassigned ready orders in the driver view:
filter: 'status=eq.ready'. When a new ready order appears within the driver's area, show a notification card 'New delivery available nearby'.When to use: After the basic order flow works end-to-end — Realtime subscriptions are useless if the underlying order lifecycle isn't correct first
Three-account RLS security audit
Structured verification that multi-role RLS policies prevent cross-account data leakage before any beta launch
Perform a structured RLS security audit with three test accounts. Create these test users in Cloud → Users & Auth: (1) customer@test.com with app_metadata {"role": "customer"}, (2) owner@test.com with app_metadata {"role": "restaurant_owner"}, (3) driver@test.com with app_metadata {"role": "driver"}.
For each of the following checks, open an incognito browser window, sign in as the specified user, open browser DevTools → Console, and run the Supabase JS query:
Check 1 — Customer cannot read other customers' orders:
As customer@test.com: await supabase.from('orders').select() — should only return orders where customer_id matches this user.
Check 2 — Customer cannot read any other customer's delivery address:
As customer@test.com: await supabase.from('orders').select('delivery_address') — verify only own orders.
Check 3 — Restaurant owner cannot read other restaurants' orders:
As owner@test.com: await supabase.from('orders').select() — should only return orders for restaurants owned by this user.
Check 4 — Driver cannot read customers' personal data:
As driver@test.com: await supabase.from('profiles').select() — should only return this driver's own profile row.
Check 5 — Anon cannot access orders:
Open an incognito window WITHOUT signing in: await supabase.from('orders').select() — should return 0 rows or error.
For any check that returns wrong data, paste the failing result here and I'll fix the RLS policy.When to use: Mandatory before any real-user beta launch — run this as the final step of the build
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
infinite recursion detected in policy for relation "profiles"An RLS policy on profiles contains a subquery that reads from profiles itself — for example checking role with SELECT role FROM profiles WHERE id = auth.uid(). This triggers the same policy again, recursively.
Replace every profiles-table lookup in RLS policies with the has_role() SECURITY DEFINER plpgsql function. The function reads from auth.jwt() directly: RETURN (auth.jwt() -> 'app_metadata' ->> 'role') = role_name; — no table lookup, no recursion. If you see this error on any other table that also references profiles in its RLS USING clause, the same fix applies: replace the subquery with has_role().
SubtleCryptoProvider cannot be used in a synchronous context. Use await constructEventAsync(...) instead of constructEvent(...)Lovable's default Stripe webhook edge function uses stripe.webhooks.constructEvent() (synchronous). Deno's WebCrypto implementation is async-only, so the sync version throws this error.
In supabase/functions/stripe-webhook/index.ts: (1) The first line of the handler must be const rawBody = await req.text(); (2) Replace constructEvent with: const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); Never call req.json() or JSON.parse before this line — raw body bytes must match what Stripe signed.Webhook signature verification failedThe webhook handler called await req.json() or JSON.parse(rawBody) before passing to constructEventAsync. Parsing the body changes the bytes, so the signature Stripe computed no longer matches the bytes you're verifying.
The pattern is non-negotiable: const rawBody = await req.text(); is line 1 of the handler. After constructEventAsync verifies the signature, then JSON.parse(rawBody) if you need the event data. Never reverse this order.
403 Forbidden on /rest/v1/orders — works in Lovable preview, fails on deployed production URLLovable preview uses the service-role key (bypasses RLS). Production uses the anon key (respects RLS). A missing RLS SELECT policy that was invisible in preview will fail silently in production, showing 403 or returning 0 rows.
Open an incognito browser window on your deployed URL, sign in as a customer, and run: const { data, error } = await supabase.from('orders').select(); in the browser console. If error is 'permission denied' or data is empty when it shouldn't be, the customer-read RLS policy is missing. Add: CREATE POLICY customer_read_own_orders ON orders FOR SELECT TO authenticated USING (customer_id = auth.uid()); Similarly check restaurant and driver policies.Realtime subscription returns 0 rows even though orders table has dataRealtime is not enabled on the orders table by default, and even when enabled it respects RLS — a test user must have a matching SELECT policy for the filter to fire events.
Step 1: Cloud → Database → Realtime → toggle ON for the orders table. Step 2: Confirm the test user is signed in with a role that has a SELECT policy on orders. Step 3: In the Realtime subscription filter, confirm the filter value (e.g. 'id=eq.UUID') matches an actual order that the current user can read under RLS. Step 4: Check Cloud → Logs → Realtime for any subscription errors.
duplicate key value violates unique constraint "orders_stripe_payment_intent_id_key"Stripe retried the webhook (Stripe retries on 5xx or timeout) and the handler tried to insert the same order row twice.
Make the webhook idempotent: change INSERT INTO orders (...) VALUES (...) to INSERT INTO orders (...) VALUES (...) ON CONFLICT (stripe_payment_intent_id) DO NOTHING. Always return HTTP 200 even when DO NOTHING fires — otherwise Stripe keeps retrying and you keep getting this error.
Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo + likely $30+ in top-ups — this is the most credit-intensive build in Tier 3; multi-role RLS debugging and Stripe Connect iteration are the two biggest sinks
Monthly run cost breakdown
~250–400 credits — extrapolated from marketplace (250+), payment integration (80–150), realtime features (10–14 per piece); not measured against a documented Lovable food-delivery build total| Item | Cost |
|---|---|
| Lovable Pro During active build; plan for top-ups | $25/mo |
| Supabase (Lovable Cloud) Free tier at MVP scale; jumps to Pro $25/mo past ~50K MAU or 500MB DB | $0–$25/mo |
| Stripe Connect No monthly base; scales linearly with order volume | 2.9% + $0.30 per transaction + 0.25% Connect fee |
| Google Maps Platform Free tier: 28K geocoding + directions requests/mo (~1K deliveries with tracking); $5/1K after that | $0–$5+/1K requests |
| Resend Free 3K emails/mo; bumps to $20/mo at ~3K orders/mo | $0 |
Scaling notes: Google Maps free tier breaks first at ~28K geocoding + directions calls per month — roughly 1K orders with delivery tracking. Throttle driver pings to 60 seconds instead of 30 and only send tracking updates when status='en_route'. Supabase Free 500MB DB handles ~100K orders with full realtime audit history before you need to upgrade.
Production checklist
Steps to take before you share the URL with real users.
Security
Run three-account RLS audit
Use the three-account RLS audit follow-up prompt. Open incognito windows for each role, verify each can only read their own data. Fix any leaking policy before any real users access the app.
Verify has_role() is SECURITY DEFINER plpgsql (not SQL)
Cloud → Database → SQL Editor: SELECT proname, prosecdef, prolang FROM pg_proc WHERE proname = 'has_role'; — confirm prosecdef = true and prolang corresponds to plpgsql. SQL functions get inlined into RLS and still recurse; plpgsql does not.
Payments
Test Stripe webhook end-to-end on deployed URL
Install Stripe CLI, run: stripe listen --forward-to https://your-domain.com/functions/v1/stripe-webhook. Place a test order using card 4242 4242 4242 4242. Confirm order appears in restaurant OrderQueue within 3 seconds. Check Cloud → Logs → Edge Functions for any webhook errors.
Verify webhook idempotency
In Stripe Dashboard → Webhooks → select your endpoint → find a recent event → click 'Resend'. The order should NOT be duplicated in the database — ON CONFLICT DO NOTHING should silently succeed.
Monitoring
Set up Stripe billing alerts
Stripe Dashboard → Settings → Billing → Notification preferences → add email alert for any charge above your expected average. Stripe Connect destination charges add 0.25% on top of standard rates — verify this matches your margin calculations.
Backups
Verify daily backups
Cloud tab → Database → Backups — confirm a backup ran today. For a live food delivery backend, consider scheduling a weekly manual export of orders as CSV for accounting purposes.
Frequently asked questions
Can Lovable really build a three-sided marketplace, or should I just buy ChowNow?
Honest answer: if you're a single restaurant, just use ChowNow at $149/mo flat with no commission. The dev cost of building and maintaining this Lovable app exceeds ChowNow's annual subscription in the first year. Build in Lovable when you are a regional aggregator competing directly with Uber Eats on commission — where owning the platform is the business model — or when you have a niche workflow (B2B catering with corporate accounts, curated ghost kitchens) that ChowNow cannot model. The three-sided RLS complexity is real; community satisfaction with Lovable is documented as lowest for multi-user platforms exactly like this one.
How do I prevent customers from seeing other customers' delivery addresses?
The orders table RLS SELECT policy for customers is: FOR SELECT TO authenticated USING (customer_id = auth.uid()). This means each customer only ever fetches rows where they are the customer — no customer can see another customer's delivery_address, delivery_lat, or delivery_lng. Verify this in an incognito window with a customer account: run supabase.from('orders').select('delivery_address') in the console. If you see rows belonging to other customers, the policy is missing or has USING (true) — replace it with USING (customer_id = auth.uid()).
Why does Stripe Connect webhook signature verification fail in Lovable?
The two common causes: (1) you called req.json() or JSON.parse before passing the body to constructEventAsync — this changes the bytes and breaks the signature match. The very first line of your webhook handler must be const rawBody = await req.text(); (2) you used constructEvent (sync) instead of constructEventAsync (async). Deno's WebCrypto is async-only. Use await stripe.webhooks.constructEventAsync(rawBody, sig, webhookSecret). If you see the SubtleCryptoProvider error, that is the second cause.
How do I handle driver location updates without burning Google Maps quota?
The starter prompt uses Google Maps for geocoding delivery addresses — that is a one-time call per order. Driver location tracking (the 30-second ping) is stored in the drivers table (current_lat, current_lng) and read by customers via Supabase Realtime — no Maps API call per ping. The Google Maps API cost comes from rendering the map tiles and calculating ETAs. To reduce quota: only show the map and request ETA when order.status='en_route', throttle driver pings to 60 seconds instead of 30, and cache the ETA calculation for 2 minutes per order. Google Maps free tier covers 28K map load + directions requests per month.
Can I add real-time order tracking with Supabase Realtime, or do I need Pusher?
Supabase Realtime handles this well for the scale of a regional food delivery app. The real-time order status follow-up prompt wires Postgres change subscriptions on the orders table to all three role views. Supabase Free tier supports 200 concurrent Realtime connections — enough for hundreds of simultaneous users. Pusher or a custom WebSocket server only makes sense when you exceed 200 concurrent connections or need sub-100ms latency for live GPS tracking, which is a different feature from order status updates.
What's the commission/payout split logic and where does it live in code?
The split logic lives entirely in the checkout edge function. When creating the Stripe PaymentIntent, set application_fee_amount (your platform's cut in cents) and transfer_data.destination (the restaurant's stripe_account_id). For example, a $20 order with 15% platform fee: total_cents = 2000, application_fee_amount = 300, the remaining $17 automatically routes to the restaurant's connected account via Stripe. The restaurant sees the transfer in their Stripe Express dashboard. You never touch the money — Stripe handles the routing. Adjust the fee percentage in the checkout function constant before going live.
How do I onboard restaurants to Stripe Connect from inside my app?
The Stripe Connect onboarding follow-up prompt adds this flow. The restaurant clicks 'Connect Stripe Payout Account' in their settings, which calls the stripe-connect-onboard edge function. That function creates a Stripe Express account, saves the account ID to restaurants.stripe_account_id, generates a Stripe AccountLink (the hosted onboarding URL), and redirects the restaurant to Stripe's onboarding flow. After completion, Stripe redirects back to your return URL where stripe-connect-return verifies the account is charges_enabled. Until stripe_account_id is set and charges_enabled is true, customers cannot check out from that restaurant.
Is this build suitable for a real launch, or should I hire a dev team?
This prompt kit gets you to a working MVP suitable for a closed beta with a handful of real restaurants and drivers. For production launch with real money flowing through Stripe Connect, you should: run the three-account RLS audit follow-up prompt, export to GitHub, do a manual code review of the checkout and webhook edge functions, and set up Stripe fraud monitoring. If scope expands to PCI compliance, driver background-check workflows, POS integration, or white-labeling for multiple operators, you're past Lovable's wheelhouse. If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.