Skip to main content
RapidDev - Software Development Agency
Lovable PromptsMarketplaceAdvanced

Build a Food Delivery Backend in Lovable

A three-sided food delivery backend — restaurants manage menus, customers order, drivers fulfill — with real-time order status via Supabase Realtime, Stripe Connect destination charges, and multi-role RLS hardened against CVE-2025-48757-style data leakage.

Time to MVP

days

Credits

~250–400 credits for full chain

Difficulty

Advanced

Cloud features

4

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.

  1. 1Click the + button next to Preview to open the Cloud panel
  2. 2Click Database — leave the Table Editor empty; the starter prompt creates all tables
  3. 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).

  1. 1Cloud tab → Users & Auth → confirm Email sign-in is enabled
  2. 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"
  3. 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.

  1. 1Cloud tab → Storage → Create bucket 'restaurant-logos', toggle Public ON
  2. 2Create bucket 'menu-photos', toggle Public ON
  3. 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.

  1. 1Cloud tab → Edge Functions — leave empty; functions deploy from supabase/functions/ automatically
  2. 2After the starter prompt runs, verify checkout, stripe-webhook, dispatch-order, get-nearby-restaurants appear here

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: 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_SECRET

Purpose: 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_ID

Purpose: 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_KEY

Purpose: 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_KEY

Purpose: 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.

lovable-agent-mode.txt
~80–120 credits
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.sql

6 tables + has_role() SECURITY DEFINER + earthdistance + RLS policies

src/layouts/CustomerLayout.tsx

Mobile-first bottom-tab nav with CartDrawer

src/layouts/RestaurantLayout.tsx

Sidebar with accepting_orders toggle

src/layouts/DriverLayout.tsx

Single-screen map area with active order card

src/components/RoleGuard.tsx

Role-based redirect from auth JWT app_metadata.role

src/pages/customer/Browse.tsx

Restaurant grid with nearby ordering

src/pages/customer/RestaurantDetail.tsx

Menu with add-to-cart

src/pages/customer/Cart.tsx

Cart items with qty controls and checkout CTA

src/pages/customer/Checkout.tsx

Delivery address + Stripe Elements payment

src/pages/customer/OrderDetail.tsx

Live order status with Realtime subscription

src/pages/restaurant/OrderQueue.tsx

Live order list with status update buttons

src/pages/restaurant/MenuEditor.tsx

Menu item CRUD with photo upload

src/pages/driver/ActiveOrder.tsx

Current delivery with En Route / Delivered buttons

supabase/functions/get-nearby-restaurants/index.ts

Earthdistance query for nearby restaurants by lat/lng

supabase/functions/checkout/index.ts

Stripe PaymentIntent with Connect destination charge

supabase/functions/stripe-webhook/index.ts

Raw-body webhook with constructEventAsync and idempotent order insert

supabase/functions/dispatch-order/index.ts

Nearest-online-driver assignment via earthdistance

Routes
/

Customer: nearby restaurant grid

/restaurant/:slug

Customer: restaurant menu and cart

/cart

Customer: cart review and checkout

/orders/:id

Customer: live order status tracker

/r/orders

Restaurant: live incoming order queue

/r/menu

Restaurant: menu item CRUD

/d/active

Driver: current active delivery

/signin

Shared sign-in with role-based redirect after auth

DB Tables
restaurants
ColumnType
iduuid primary key
owner_iduuid references auth.users
nametext not null
stripe_account_idtext nullable
accepting_ordersbool default false

RLS: Public read where accepting_orders=true. Restaurant owner write via has_role('restaurant_owner') AND owner_id = auth.uid().

orders
ColumnType
iduuid primary key
customer_iduuid references auth.users
restaurant_iduuid references restaurants
driver_iduuid nullable references auth.users
statustext check in ('placed','preparing','ready','en_route','delivered','cancelled')
stripe_payment_intent_idtext 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
ColumnType
iduuid references auth.users primary key
onlinebool default false
current_latfloat
current_lngfloat
last_pingtimestamptz

RLS: Per-driver write own. Restaurant SELECT for drivers assigned to their orders only.

profiles
ColumnType
iduuid references auth.users primary key
roletext check in ('customer','restaurant_owner','driver','admin')
full_nametext
phonetext

RLS: Per-user read+write own. Role set by admin via app_metadata, not client.

menu_items
ColumnType
iduuid primary key
restaurant_iduuid references restaurants
nametext not null
price_centsint not null
availablebool default true

RLS: Public read where available. Restaurant owner write via inner join to restaurants.owner_id.

order_items
ColumnType
iduuid primary key
order_iduuid references orders
menu_item_iduuid references menu_items
qtyint not null
unit_price_centsint not null

RLS: Inherits via order_id — customer, restaurant, and driver can each read line items for orders they can access.

Components
RoleGuard

Reads JWT app_metadata.role, redirects if role doesn't match

CartDrawer

Bottom Sheet with cart items, qty controls, subtotal, checkout CTA

LiveOrderTracker

Step indicator with Realtime subscription to orders table

OrderStatusBadge

Color-coded badge for all 6 order statuses

MenuItemRow

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

1

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

~30 credits
prompt
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

2

Add Stripe Connect restaurant onboarding

Full Stripe Connect Express onboarding so restaurants can receive destination-charge payouts

~60 credits
prompt
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

3

Harden the Stripe webhook: raw body + constructEventAsync + idempotency

Correct raw-body Stripe webhook with constructEventAsync, idempotent order insert, and fast 200 response

~80 credits
prompt
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

4

Add real-time order status across all three roles

Live order status updates pushed to all three role views via Supabase Realtime subscriptions

~50 credits
prompt
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

5

Three-account RLS security audit

Structured verification that multi-role RLS policies prevent cross-account data leakage before any beta launch

~40 credits
prompt
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.

Fix — paste into Lovable Agent Mode
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.

Fix — paste into Lovable Agent Mode
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 failed

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

Fix — paste into Lovable Agent Mode
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 URL

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

Fix — paste into Lovable Agent Mode
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 data

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

Fix — paste into Lovable Agent Mode
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.

Fix — paste into Lovable Agent Mode
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
ItemCost
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 consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.