Skip to main content
RapidDev - Software Development Agency
Lovable PromptsE-commerceIntermediate

Build a Shopping Cart in Lovable

A single-seller storefront with product catalog, persistent dual-mode cart (DB + localStorage), Stripe Checkout, and a raw-body webhook that creates the order and atomically decrements stock — preventing overselling even under concurrent checkouts.

Time to MVP

1 day

Credits

~100–180 credits for full chain

Difficulty

Intermediate

Cloud features

4

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Build mode and get a working single-seller storefront: product catalog, persistent cart (DB-backed for signed-in users, localStorage for guests), Stripe Checkout, and a webhook that creates orders and decrements stock atomically. The raw-body webhook pattern and stock-decrement timing are the two non-negotiable pieces. Full chain: ~100–180 credits on Pro $25/mo, about one full day.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores products, variants, carts, cart_items, orders, and order_items. The starter prompt creates all six tables plus the decrement_stock() RPC function.

  1. 1Click the + button next to Preview to open the Cloud panel
  2. 2Click Database — leave the Table Editor empty; the starter prompt generates the migration
  3. 3After the migration runs, verify the decrement_stock function appears in Cloud → Database → Functions

Auth

Email/password for signed-in customers and the admin. Guest checkout works without an account — auth is optional.

  1. 1Cloud tab → Users & Auth → confirm Email sign-in is enabled
  2. 2Leave 'Allow new users to sign up' ON — customers self-register
  3. 3After building, create your admin user via Cloud → Users & Auth → Add user, then set app_metadata to {"role": "admin"}

Storage

Stores product images uploaded by the admin. One public bucket so images render on the public catalog.

  1. 1Cloud tab → Storage → Create bucket 'product-images', toggle Public ON
  2. 2The starter prompt references 'product-images' throughout — do not rename it

Edge Functions

create-checkout-session creates the Stripe Checkout Session server-side (Stripe secret key never touches the browser). stripe-webhook handles the payment confirmation with the mandatory raw-body pattern.

  1. 1Cloud tab → Edge Functions — leave empty; functions deploy from supabase/functions/ automatically
  2. 2After the starter prompt runs, verify create-checkout-session and stripe-webhook appear here
  3. 3After deploying your app, register the webhook URL in the Stripe dashboard: https://your-domain.com/functions/v1/stripe-webhook

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: Creates Stripe Checkout Sessions in create-checkout-session edge function. Never expose in client-side code or VITE_ env vars.

Where to get it: https://dashboard.stripe.com/apikeys — copy the secret key starting with sk_test_ (for testing) or sk_live_ (for production). Start with test key.

STRIPE_WEBHOOK_SECRET

Purpose: Verifies Stripe webhook signatures via constructEventAsync to prevent spoofed webhook calls

Where to get it: https://dashboard.stripe.com/webhooks — create a new webhook endpoint, set URL to https://your-deployed-domain/functions/v1/stripe-webhook, check checkout.session.completed and payment_intent.succeeded events, copy the signing secret starting with whsec_

RESEND_API_KEY

Purpose: Sends order confirmation emails with line items and shipping address after successful payment

Where to get it: https://resend.com/api-keys — free account, Create API Key with Sending access, copy key starting with re_

SITE_URL

Purpose: Used as the success_url and cancel_url in Stripe Checkout Sessions — must be a publicly reachable URL, not the Lovable preview

Where to get it: After publishing (top-right Publish button), copy the production URL or custom domain

Preflight checklist

  • You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
  • You're on Pro $25/mo — Stripe webhook iteration alone uses 30+ credits; Free's daily cap blocks you mid-debug
  • You have a Stripe account at stripe.com — test mode is fine during build (no real money)
  • Critical: Stripe MUST be tested on a deployed URL. The Lovable preview iframe cannot receive Stripe redirects or webhooks — Publish the project before any Stripe testing
  • Stock is decremented ONLY in the Stripe webhook, not at add-to-cart and not at checkout redirect — this is the most-skipped pattern and it matters

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
~50–80 credits
Build a single-seller e-commerce storefront. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6, Supabase JS client against Lovable Cloud.

## Database schema (migration: supabase/migrations/0001_shop_schema.sql)

Create six tables:

1. `products` — id (uuid pk default gen_random_uuid()), slug (text unique not null), name (text not null), description (text), price_cents (int not null check (price_cents > 0)), stock (int not null default 0 check (stock >= 0)), image_path (text), archived (bool default false), created_at (timestamptz default now()).
   RLS: enable. FOR SELECT TO anon, authenticated USING (archived = false). FOR INSERT/UPDATE/DELETE TO authenticated USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin').

2. `variants` — id (uuid pk default gen_random_uuid()), product_id (uuid references products.id on delete cascade not null), name (text not null), price_cents (int nullable), stock (int not null default 0 check (stock >= 0)), created_at (timestamptz default now()).
   RLS: enable. FOR SELECT TO anon, authenticated USING (EXISTS (SELECT 1 FROM products p WHERE p.id = variants.product_id AND p.archived = false)). FOR INSERT/UPDATE/DELETE TO authenticated USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin').

3. `carts` — id (uuid pk default gen_random_uuid()), user_id (uuid references auth.users.id on delete cascade not null unique), created_at (timestamptz default now()), updated_at (timestamptz default now()).
   RLS: enable. All operations: USING (user_id = auth.uid()) / WITH CHECK (user_id = auth.uid()).

4. `cart_items` — id (uuid pk default gen_random_uuid()), cart_id (uuid references carts.id on delete cascade not null), product_id (uuid references products.id not null), variant_id (uuid references variants.id nullable), qty (int not null check (qty > 0)), UNIQUE (cart_id, product_id, variant_id).
   RLS: enable. All operations: USING (EXISTS (SELECT 1 FROM carts c WHERE c.id = cart_items.cart_id AND c.user_id = auth.uid())).

5. `orders` — id (uuid pk default gen_random_uuid()), customer_email (text not null), customer_user_id (uuid references auth.users.id nullable), stripe_session_id (text unique not null), stripe_payment_intent_id (text unique), subtotal_cents (int not null), tax_cents (int not null default 0), total_cents (int not null), status (text not null default 'pending' check (status in ('pending','paid','fulfilled','refunded','cancelled'))), shipping_address (jsonb), placed_at (timestamptz default now()).
   RLS: enable. FOR SELECT TO authenticated USING (customer_user_id = auth.uid()). FOR SELECT admin: USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'). No INSERT for clients — created by webhook only.

6. `order_items` — id (uuid pk default gen_random_uuid()), order_id (uuid references orders.id not null), product_id (uuid references products.id not null), variant_id (uuid references variants.id nullable), qty (int not null), unit_price_cents (int not null), product_name_snapshot (text not null).
   RLS: enable. FOR SELECT: inherits via order — USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.customer_user_id = auth.uid() OR (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin'))).

Create a stock-decrement RPC function:
CREATE OR REPLACE FUNCTION decrement_stock(p_product uuid, p_variant uuid, p_qty int)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  IF p_variant IS NOT NULL THEN
    UPDATE variants SET stock = stock - p_qty WHERE id = p_variant;
    IF NOT FOUND OR (SELECT stock FROM variants WHERE id = p_variant) < 0 THEN
      RAISE EXCEPTION 'insufficient_stock: variant %', p_variant;
    END IF;
  ELSE
    UPDATE products SET stock = stock - p_qty WHERE id = p_product;
    IF NOT FOUND OR (SELECT stock FROM products WHERE id = p_product) < 0 THEN
      RAISE EXCEPTION 'insufficient_stock: product %', p_product;
    END IF;
  END IF;
END;
$$;

## Layouts

`src/layouts/PublicLayout.tsx` — top nav: Logo / Shop link / Cart icon (from lucide-react) with count badge / Sign in button. Cart icon opens CartDrawer (Sheet from right). Footer with copyright.

`src/layouts/AdminLayout.tsx` — sidebar: Products / Orders / Settings. Protected by AdminGuard that checks app_metadata.role === 'admin'.

## Pages (add to src/App.tsx)

- `/` — Catalog.tsx: product grid (3-col desktop, 1-col mobile), each card shows image (from Storage public URL), name, price. Use shadcn Card. Empty state when no products.
- `/product/[slug]` — ProductDetail.tsx: large image, name, description, price, stock count. If product has variants, show VariantSelector. Add to Cart button: signed-in → insert/update cart_items in DB; guest → write to localStorage as JSON array [{product_id, variant_id, qty}]. Show CartDrawer on success.
- `/cart` — Cart.tsx: reads from DB (signed-in) or localStorage (guest). CartItemRow for each line item with qty stepper and remove button. Order subtotal. 'Checkout' button → POSTs to create-checkout-session edge function.
- `/checkout` — Checkout.tsx: simple redirect wrapper — on mount it calls create-checkout-session and redirects to Stripe Checkout URL. Shows loading spinner.
- `/success` — Success.tsx: reads session_id from URL query param, shows 'Order confirmed!' message and links to /orders.
- `/orders` — Orders.tsx: signed-in only (redirect to /signin if not). Table of past orders with status badges.
- `/signin` and `/signup` — basic email+password forms. On successful sign-in, call the cart-merge logic (see useCart hook).
- `/admin/products` — admin/Products.tsx: table of all products with edit Sheet (name, price, stock, image upload, archived toggle). Add Product button.
- `/admin/orders` — admin/Orders.tsx: table of all orders with status filter chips and update button.

## Key components and hooks

`src/hooks/useCart.ts` — manages dual-mode cart:
- If signed in: read/write cart_items from DB via Supabase JS. Use supabase.from('carts').select('*, cart_items(*, products(*), variants(*))') joined.
- If guest: read/write from localStorage key 'cart_items' as JSON.
- Export: { items, addItem, updateQty, removeItem, clearCart, mergeGuestCart, cartCount, subtotalCents }.
- mergeGuestCart(): called after sign-in. Reads localStorage, upserts each item into the DB cart using INSERT INTO cart_items ON CONFLICT (cart_id, product_id, variant_id) DO UPDATE SET qty = cart_items.qty + EXCLUDED.qty, then clears localStorage.

`src/components/CartDrawer.tsx` — Sheet from right. Shows cart items from useCart, qty steppers, subtotal, 'Go to Cart' button.

`src/components/CartItemRow.tsx` — image thumbnail, name + variant, unit price, qty stepper, remove button.

`src/components/ProductCard.tsx` — product image, name, price. Click → /product/[slug].

`src/components/VariantSelector.tsx` — renders variant chips (e.g. Size: S / M / L). Updates selected variant state.

`src/components/AdminGuard.tsx` — reads JWT app_metadata.role, redirects to /unauthorized if not admin.

## Edge functions

`supabase/functions/create-checkout-session/index.ts` — POST JSON { cart_items: [{product_id, variant_id, qty, name, unit_price_cents}], customer_email, shipping_address, customer_user_id (nullable) }. Steps:
1. CORS preflight.
2. Use service-role Supabase client.
3. Validate each cart_item: fetch product (and variant if set), check archived=false and stock > 0.
4. Create Stripe Checkout Session: mode: 'payment', line_items from cart, automatic_payment_methods: { enabled: true } (do NOT set payment_method_types — these conflict in 2024+ Stripe API), success_url: SITE_URL + '/success?session_id={CHECKOUT_SESSION_ID}', cancel_url: SITE_URL + '/cart', metadata: { cart_items: JSON.stringify(cart_items), customer_user_id: customer_user_id ?? '', customer_email }.
5. Return { url: session.url } to client which immediately redirects.

`supabase/functions/stripe-webhook/index.ts` — CRITICAL: (1) const rawBody = await req.text(); MUST be line 1. (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 === 'checkout.session.completed': parse metadata from event.data.object.metadata. (6) INSERT INTO orders (..., stripe_session_id) ON CONFLICT (stripe_session_id) DO NOTHING — idempotent. (7) For each cart_item in metadata: INSERT INTO order_items (..., product_name_snapshot). (8) Call decrement_stock RPC for each line item: supabase.rpc('decrement_stock', { p_product: product_id, p_variant: variant_id, p_qty: qty }). On exception ('insufficient_stock'), call stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) and update order.status='refunded'. (9) Send confirmation email via Resend. (10) Return 200.

## Styling

Clean commerce: white background, slate text, single indigo-600 accent. Product grid: rounded-xl cards with shadow-sm on hover. Cart drawer: clean list, line items left-aligned with price right-aligned. Admin: table-heavy, desktop-first. Use shadcn Card, Sheet, Dialog, Badge, Toast, Skeleton, Table.

Generate migration (with decrement_stock function) first, then layouts, then pages in order, then edge functions.

What this prompt generates

  • Creates a SQL migration with 6 tables, admin-only RLS policies, and a decrement_stock() RPC function that raises an exception on insufficient stock
  • Scaffolds PublicLayout with cart drawer and AdminLayout with AdminGuard
  • Generates 9 public pages (catalog, product detail, cart, checkout, success, orders, signin, signup) and 2 admin pages (products, orders)
  • Creates useCart hook with dual DB/localStorage backing and mergeGuestCart function for cart merge on sign-in
  • Creates create-checkout-session edge function and stripe-webhook edge function with raw-body pattern and constructEventAsync

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_shop_schema.sql

6 tables + RLS + decrement_stock() RPC

src/layouts/PublicLayout.tsx

Top nav with cart icon + count badge + CartDrawer

src/layouts/AdminLayout.tsx

Sidebar layout with AdminGuard

src/components/AdminGuard.tsx

JWT app_metadata.role check, redirects non-admins

src/hooks/useCart.ts

Dual DB/localStorage cart with merge on sign-in

src/pages/Catalog.tsx

Product grid with empty state

src/pages/ProductDetail.tsx

Product detail with VariantSelector and add-to-cart

src/pages/Cart.tsx

Cart review with CartItemRow components

src/pages/Checkout.tsx

Calls edge function, redirects to Stripe Checkout URL

src/pages/Success.tsx

Order confirmed page with order link

src/pages/Orders.tsx

Signed-in customer's order history

src/pages/admin/Products.tsx

Admin product CRUD with image upload

src/pages/admin/Orders.tsx

Admin order table with status filters

src/components/CartDrawer.tsx

Sheet drawer with cart items and checkout CTA

src/components/VariantSelector.tsx

Variant chip selector (size, color etc)

src/components/ProductCard.tsx

Catalog card with image, name, price

supabase/functions/create-checkout-session/index.ts

Stripe Checkout Session creation with line_items

supabase/functions/stripe-webhook/index.ts

Raw-body webhook: order insert + stock decrement + refund on oversell

Routes
/

Public product catalog grid

/product/:slug

Product detail with variant selector and add to cart

/cart

Cart review — DB or localStorage depending on auth

/checkout

Redirect to Stripe Checkout

/success

Order confirmed, links to order history

/orders

Signed-in customer order history

/signin

Customer email+password sign-in with cart merge

/admin/products

Admin: product CRUD with image upload

/admin/orders

Admin: order table with status management

DB Tables
products
ColumnType
iduuid primary key
slugtext unique not null
nametext not null
price_centsint not null
stockint not null default 0
image_pathtext nullable
archivedbool default false

RLS: Public read where archived=false. Admin write only via app_metadata.role='admin'.

orders
ColumnType
iduuid primary key
customer_emailtext not null
customer_user_iduuid nullable references auth.users
stripe_session_idtext unique not null
total_centsint not null
statustext check in ('pending','paid','fulfilled','refunded','cancelled')
shipping_addressjsonb

RLS: Customer reads own (customer_user_id = auth.uid()). Admin reads all. No client INSERT — webhook only. Unique on stripe_session_id for idempotency.

cart_items
ColumnType
iduuid primary key
cart_iduuid references carts
product_iduuid references products
variant_iduuid nullable references variants
qtyint not null check (qty > 0)

RLS: Per-cart access via inner join to carts.user_id = auth.uid(). Unique on (cart_id, product_id, variant_id).

order_items
ColumnType
iduuid primary key
order_iduuid references orders
product_iduuid references products
qtyint not null
unit_price_centsint not null
product_name_snapshottext not null

RLS: Inherits via order. Customer and admin read own orders' items.

carts
ColumnType
iduuid primary key
user_iduuid unique references auth.users

RLS: Per-user — all operations USING (user_id = auth.uid()).

variants
ColumnType
iduuid primary key
product_iduuid references products
nametext not null
price_centsint nullable
stockint not null default 0

RLS: Public read via parent product visibility. Admin write only.

Components
CartDrawer

Sheet drawer from right with cart items, subtotal, and checkout CTA

ProductCard

Catalog card showing image, name, and price

VariantSelector

Chip selector for product variants (size, color, etc)

CartItemRow

Line item with image, name, qty stepper, remove button

AdminGuard

Checks JWT app_metadata.role='admin', redirects otherwise

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Harden the Stripe webhook: raw body + constructEventAsync + idempotency

Correct raw-body webhook pattern, constructEventAsync, idempotent order insert, and automatic Stripe refund on stock exhaustion

~30 credits
prompt
Update supabase/functions/stripe-webhook/index.ts with the mandatory raw-body pattern:

The VERY FIRST line of the handler 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.

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 — the sync version throws 'SubtleCryptoProvider cannot be used in a synchronous context'.

For idempotency: INSERT INTO orders (..., stripe_session_id) ... ON CONFLICT (stripe_session_id) DO NOTHING. If the conflict fires, still return 200 so Stripe stops retrying.

For the stock decrement: call the decrement_stock RPC in a try/catch. On exception message starting with 'insufficient_stock', call stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) and update orders.status='refunded' and orders.notes='oversold'.

Always return 200 within 5 seconds to prevent Stripe retries.

When to use: Paste immediately after the starter prompt — this is the non-negotiable foundation of Stripe integration

2

Add atomic stock decrement in webhook with refund on oversell

Correct stock-decrement timing with automatic Stripe refund if two customers simultaneously purchase the last unit

~40 credits
prompt
Ensure the stripe-webhook edge function calls decrement_stock correctly and handles the oversell case:

For each order_item in the order, after inserting the order_items row, call:
const { error: decrementError } = await supabase.rpc('decrement_stock', { p_product: item.product_id, p_variant: item.variant_id || null, p_qty: item.qty });

If decrementError exists and decrementError.message includes 'insufficient_stock':
1. Update orders.status = 'refunded', orders.notes = 'oversold - auto-refunded' using the service-role client.
2. Call await stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) to refund the customer automatically.
3. Send an 'Out of stock — refund issued' email via Resend to the customer_email.
4. Log the error but still return 200 so Stripe does not retry.

If decrement succeeds, update orders.status = 'paid'.

This flow means two simultaneous checkouts for the last unit: first webhook succeeds and decrements stock to 0. Second webhook hits the decrement_stock RAISE EXCEPTION (stock would go negative), catches it, refunds via Stripe, updates order status, and emails the customer.

When to use: Immediately after the raw-body webhook fix — these two prompts form an inseparable pair

3

Add cart merge on sign-in

Seamless cart merge when a guest signs in mid-shopping — prevents silent cart loss

~25 credits
prompt
Update the sign-in flow to merge guest localStorage cart into the DB cart:

1. In src/hooks/useCart.ts, ensure the mergeGuestCart() function is complete:
   - Read localStorage key 'cart_items' as JSON (array of {product_id, variant_id, qty})
   - If empty or null, return early
   - Ensure the user has a carts row: supabase.from('carts').upsert({ user_id: userId }).select().single()
   - For each guest item: supabase.from('cart_items').upsert({ cart_id: cart.id, product_id: item.product_id, variant_id: item.variant_id, qty: item.qty }, { onConflict: 'cart_id,product_id,variant_id' }) — the upsert's onConflict behavior: if row exists, update qty = existing_qty + new_qty. Implement this as: first SELECT the existing qty, then INSERT with (existing_qty + item.qty) or INSERT with item.qty if no existing row.
   - After all upserts, clear localStorage: localStorage.removeItem('cart_items')
   - Re-fetch the DB cart to update state

2. In src/pages/Signin.tsx, after supabase.auth.signInWithPassword succeeds, call useCart().mergeGuestCart() before navigating to /cart.

3. Show a toast after merge: 'Your cart has been saved to your account.'

When to use: High-value UX win — any store with guest checkout should implement this before launch

4

Add order confirmation emails via Resend

Order confirmation email with line items, totals, and order link sent after successful Stripe payment

~30 credits
prompt
Add order confirmation emails in the stripe-webhook edge function after successful payment:

After orders.status is set to 'paid' and order_items are inserted, send a confirmation email via Resend:

const emailBody = `
Thank you for your order!

Order #${order.id.slice(0, 8).toUpperCase()}

${orderItems.map(i => `${i.product_name_snapshot} × ${i.qty} — $${(i.unit_price_cents * i.qty / 100).toFixed(2)}`).join('\n')}

Subtotal: $${(order.subtotal_cents / 100).toFixed(2)}
Total: $${(order.total_cents / 100).toFixed(2)}

Shipping to: ${JSON.stringify(order.shipping_address)}

View your order: ${Deno.env.get('SITE_URL')}/orders/${order.id}
`;

await fetch('https://api.resend.com/emails', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${Deno.env.get('RESEND_API_KEY')}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    from: 'orders@your-domain.com',
    to: order.customer_email,
    subject: `Order confirmed — #${order.id.slice(0, 8).toUpperCase()}`,
    text: emailBody
  })
});

Make sure RESEND_API_KEY and SITE_URL are set in Cloud → Secrets and your Resend domain is verified.

When to use: Once RESEND_API_KEY is in Secrets and your Resend sending domain has verified SPF + DKIM records

5

Add discount codes table and checkout integration

Discount code system with server-side validation, Stripe integration, and usage tracking

~40 credits
prompt
Add discount codes to the checkout flow:

1. Create a new table: discount_codes — id (uuid pk), code (text unique not null), type (text check (type in ('percent','fixed'))), value (int not null — percent 0-100 or fixed cents), max_uses (int nullable), used_count (int default 0), expires_at (timestamptz nullable), active (bool default true).
   RLS: no client reads (codes are validated server-side only). Admin writes via app_metadata.role='admin'.

2. In Cart.tsx, add a 'Discount code' input field and 'Apply' button above the subtotal. When Apply is clicked, call a new edge function validate-discount with { code: string, cart_subtotal_cents: int }. Show the discount amount or error.

3. Create supabase/functions/validate-discount/index.ts: looks up the code, checks active=true, expires_at > now(), and used_count < max_uses (if max_uses set). Returns { valid: true, type, value, discount_amount_cents } or { valid: false, reason }.

4. Pass the discount code to create-checkout-session. In that function, apply the discount: for a percent discount, pass discounts: [{ coupon: stripe_coupon_id }] to Stripe Checkout (create the coupon in Stripe dynamically with stripe.coupons.create). For a fixed discount, adjust the line item prices.

5. In the stripe-webhook handler, increment discount_codes.used_count by 1 after a successful order.

6. In /admin/products, add a 'Discount Codes' tab with CRUD for codes.

When to use: High-conversion feature — even a simple 10% launch discount can meaningfully increase initial sales

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

SubtleCryptoProvider cannot be used in a synchronous context. Use await constructEventAsync(...) instead of constructEvent(...)

Lovable's default Stripe webhook scaffold uses stripe.webhooks.constructEvent() (synchronous). Deno's WebCrypto is async-only, so the sync version throws immediately.

Fix — paste into Lovable Agent Mode
In supabase/functions/stripe-webhook/index.ts: line 1 of the handler must be const rawBody = await req.text(); Then: const event = await stripe.webhooks.constructEventAsync(rawBody, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET')); Never call req.json() or JSON.parse before constructEventAsync.
Webhook signature verification failed

The webhook handler called req.json() or JSON.parse(await req.text()) before passing to constructEventAsync. The bytes Stripe signed are the exact original bytes — parsing changes them.

Fix — paste into Lovable Agent Mode
Verify that const rawBody = await req.text(); is the absolute first line of the handler, before any await req.json(), before any JSON.parse, before the CORS check. After constructEventAsync verifies the signature, then call JSON.parse(rawBody) if you need the structured event data.

Manual fix

If you see this in Cloud → Logs → Edge Functions, check the function for any req.json() call. That line must be removed. The entire handler should read rawBody as text and pass that directly to constructEventAsync.

negative stock value detected — insufficient_stock: product abc123 (custom exception from decrement_stock)

Two simultaneous purchases of the last unit both passed the checkout session creation (which does not lock stock), then both webhooks fired and the second decrement_stock call found stock would go below 0.

Fix — paste into Lovable Agent Mode
This is the decrement_stock function working correctly — it raised the exception. Your webhook handler should have caught this and issued a Stripe refund. If the webhook handler did not catch it: add a try/catch around the supabase.rpc('decrement_stock', ...) call. On error.message.includes('insufficient_stock'), call stripe.refunds.create({ payment_intent: event.data.object.payment_intent }) and update orders.status='refunded'.
Stripe Checkout returns error: 'You may only specify one of these parameters: payment_method_types'

Lovable scaffolded the Checkout Session with both payment_method_types: ['card'] and automatic_payment_methods: { enabled: true }. These conflict in the 2024+ Stripe API.

Fix — paste into Lovable Agent Mode
In create-checkout-session edge function, remove the payment_method_types line entirely. Keep only automatic_payment_methods: { enabled: true }. Stripe will automatically offer the payment methods configured in your Stripe Dashboard based on the customer's location and currency.
Stripe integration doesn't work in Lovable preview — redirect to checkout.stripe.com fails

Lovable's preview iframe blocks cross-origin redirects to checkout.stripe.com. The webhook also cannot reach the preview URL since it's behind Lovable's internal proxy.

Manual fix

Stripe MUST be tested on a deployed URL. Click the Publish button (top-right), copy the production URL, configure the Stripe webhook endpoint in the Stripe Dashboard to point at https://your-domain/functions/v1/stripe-webhook, and test with card number 4242 4242 4242 4242, any future expiry, any CVC.

Cart count badge shows stale number after adding an item

useCart hook reads cart count once on mount but does not subscribe to real-time changes on cart_items, so adding an item updates the DB but the badge doesn't reflect it until page refresh.

Fix — paste into Lovable Agent Mode
In useCart.ts, after the initial cart fetch, add a Supabase Realtime channel subscription on cart_items filtered by cart_id. On any INSERT/UPDATE/DELETE event, call the refetch function to update the items array and recompute cartCount. For the guest localStorage path, dispatch a custom window event 'cart-updated' after every mutation and listen for it in the badge component.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo — Stripe webhook iteration alone will consume 30+ credits; Free's 30/mo cap blocks you mid-debug of the most important part of the build

Monthly run cost breakdown

~100–180 credits — starter ~50, raw-body webhook fix ~30, stock decrement ~40, cart merge ~25, email confirmation ~30, plus 2 retry rounds on webhook total
ItemCost
Lovable Pro

During build only — cancel after you stop iterating

$25/mo
Supabase (Lovable Cloud)

Products and orders table stays small for indie commerce; fine up to ~5K orders

$0
Stripe

No monthly fee; scales linearly. A $50 average order at 100 orders/mo = ~$16/mo in Stripe fees

2.9% + $0.30 per transaction
Resend

Free 3K emails/mo; bumps to $20/mo at ~3K orders/mo

$0
Custom domain

Via Lovable Publish → Custom Domain

~$12/yr

Scaling notes: Stripe's percentage fee scales linearly forever — no plan jump. Supabase Free is fine up to ~5K orders before storage approaches 500MB (depending on image sizes). Resend free tier breaks at ~3K orders/mo. The real scaling cost is Stripe fees: at $100K/mo GMV you are paying $3,100/mo in Stripe fees — at that point Shopify Payments at 2.5% ($2,500/mo) becomes worth a look.

Production checklist

Steps to take before you share the URL with real users.

Domain & Stripe Setup

  • Deploy and register Stripe webhook

    Publish the app (top-right Publish button), copy the production URL, go to Stripe Dashboard → Webhooks → Add endpoint, set URL to https://your-domain.com/functions/v1/stripe-webhook, select events: checkout.session.completed and payment_intent.succeeded, copy the webhook secret to Cloud → Secrets → STRIPE_WEBHOOK_SECRET.

  • Update SITE_URL secret

    Cloud → Secrets → set SITE_URL to your production domain. Without this, Stripe success_url and cancel_url point to undefined and customers can't return after payment.

Testing

  • End-to-end Stripe test

    On the deployed URL, add a product to cart, go to checkout, use Stripe test card 4242 4242 4242 4242 with any future date and CVC. Verify: (1) redirect to success page, (2) order appears in /orders, (3) stock decremented in admin, (4) confirmation email received.

  • Oversell test

    Set a product stock to 1, open two incognito windows simultaneously, add the same product to cart in both, submit both checkouts at the same time. One should succeed; the other should receive a Stripe refund and see an 'Out of stock' email.

Email

  • Verify Resend sending domain

    Resend dashboard → Domains → Add Domain → add SPF + DKIM DNS records at your registrar → wait for green status. Without this, order confirmation emails go to spam or bounce.

Go Live

  • Switch from Stripe test to live keys

    In Stripe Dashboard, toggle from Test to Live mode, copy the live sk_live_ key to STRIPE_SECRET_KEY in Cloud → Secrets. Create a new webhook endpoint in Live mode (test mode webhooks don't fire in live mode) and update STRIPE_WEBHOOK_SECRET with the live webhook secret.

Frequently asked questions

Why won't Stripe work in Lovable preview?

Lovable's preview runs in an iframe on Lovable's domain. When your checkout edge function returns a Stripe Checkout URL and your code does window.location.href = url, the iframe blocks the redirect to checkout.stripe.com as a cross-origin navigation. The Stripe webhook also cannot reach the preview URL since it is behind Lovable's internal proxy and not a publicly reachable endpoint. Both issues are solved by publishing your project (top-right Publish button) and testing on the deployed URL. Register the webhook at Stripe Dashboard → Webhooks with your production URL before any Stripe testing.

Where in the flow should I decrement stock — at add-to-cart, checkout, or webhook?

Always in the webhook, after payment succeeds. Decrement at add-to-cart and you permanently lock inventory for every abandoned cart. Decrement at checkout submit and customers whose Stripe payment fails leave stock as sold. Decrement in the webhook and you only touch stock after Stripe confirms the money cleared. The decrement_stock() function in the starter prompt raises an exception if stock goes below zero, which the webhook handler catches and uses to trigger an automatic Stripe refund — this is the only way to handle the edge case of two simultaneous purchases of the last unit.

How do I merge a guest cart when the user signs in mid-checkout?

The cart merge follow-up prompt adds this to useCart.ts. After a successful sign-in, mergeGuestCart() reads the localStorage cart, upserts each item into the user's DB cart (adding qtys on conflict rather than duplicating), then clears localStorage. The key is the upsert conflict behavior on (cart_id, product_id, variant_id) — if the user already had the same item in a previous signed-in session, the quantities should sum, not create a duplicate line item. Call mergeGuestCart() in Signin.tsx immediately after signInWithPassword succeeds, before navigating anywhere.

What's the deal with constructEventAsync and Lovable's edge function?

Stripe's webhook signature verification requires HMAC with SHA-256, which uses the WebCrypto API. In Deno (which Lovable Edge Functions run on), WebCrypto is async-only. Stripe's synchronous constructEvent() tries to call WebCrypto synchronously and throws 'SubtleCryptoProvider cannot be used in a synchronous context'. The fix is to use constructEventAsync() with await. The second gotcha: you must pass the raw text body — the exact bytes Stripe signed. If you call req.json() before constructEventAsync, the bytes change and the signature fails. Line 1 of your webhook handler must always be const rawBody = await req.text().

Can I add product variants (size, color) to the schema later?

The starter prompt includes the variants table from the beginning. The VariantSelector component renders variant chips on the product detail page, and both the cart and webhook handle the optional variant_id field. If you initially seed products without variants, they work fine — variant_id is nullable throughout. To add variants to an existing product, use the admin Products page to add variant rows in the edit Sheet. The decrement_stock function handles both cases: if p_variant is not null, it decrements variants.stock; otherwise it decrements products.stock.

How do I handle abandoned carts and email recovery?

The starter prompt does not include abandoned cart recovery — it is a significant addition. The pattern: on checkout page load, save the customer_email to the cart row. Create a pg_cron job that runs every hour and finds carts updated more than 1 hour ago with no corresponding completed order. For each, send a Resend email to the stored email: 'You left something in your cart — here's a link to pick up where you left off.' This requires collecting customer_email before the Stripe redirect, which means adding an email field to your Checkout.tsx before the Stripe button. Budget ~40 credits for this follow-up.

Should I really build this instead of just using Shopify?

For physical goods at any real volume, just use Shopify — $39/mo gets you tax tables, shipping rate engines, fraud detection, and 1,000+ themes you don't want to recreate. Build in Lovable when: you have fewer than 20 SKUs and want owned checkout without Shopify's fee structure, you're integrating commerce into an existing Lovable app where Shopify embed feels wrong, or you sell digital goods and Lemon Squeezy's 5% Merchant of Record fee also feels heavy. 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.