# How to Build a Subscription Box Service with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a subscription box service in Lovable with Stripe recurring billing synced to a Supabase fulfillment pipeline. Subscribers pick a plan from comparison Cards, a webhook handler creates shipment records on each billing cycle, and a Calendar shows the next box dispatch date — all with real-time shipment tracking.

## Before you start

- Lovable Pro account for multi-function Edge Function generation
- Stripe account with STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
- Supabase project with service role key in Secrets
- Deployed Lovable app URL for Stripe webhook configuration and payment flows
- Understanding that Stripe billing events trigger shipment creation — no manual dispatch needed

## Step-by-step guide

### 1. Set up the subscription box schema

Create the tables for box plans, subscriptions, shipments, and box contents. The schema models the full lifecycle from plan selection to delivered box.

```
Create a subscription box service schema in Supabase:

Tables:
- box_plans: id (uuid pk), name (text), description (text), items_per_box (int), price_cents (int), currency (text default 'usd'), stripe_price_id (text unique), interval (text default 'month'), box_value_cents (int, retail value of items), is_active (bool default true), is_featured (bool default false), image_url (text), dispatch_offset_days (int default 3), sort_order (int), created_at

- box_subscriptions: id (uuid pk), user_id (uuid references auth.users unique), stripe_subscription_id (text unique), stripe_customer_id (text), plan_id (uuid references box_plans), status (text: active|canceled|paused|past_due), shipping_address (jsonb), preferences (jsonb default '{}'), current_period_start (timestamptz), current_period_end (timestamptz), cancel_at_period_end (bool default false), created_at, updated_at

- shipments: id (uuid pk), subscription_id (uuid references box_subscriptions), user_id (uuid references auth.users), stripe_invoice_id (text unique), plan_id (uuid references box_plans), status (text default 'created': created|packed|shipped|delivered|returned), tracking_number (text nullable), tracking_url (text nullable), carrier (text nullable), dispatch_date (date), billing_period_start (timestamptz), billing_period_end (timestamptz), delivered_at (timestamptz nullable), notes (text nullable), created_at, updated_at

- box_contents: id (uuid pk), plan_id (uuid references box_plans), billing_month (text, e.g. '2026-04'), item_name (text), item_description (text), item_image_url (text nullable), retail_value_cents (int), created_at

RLS:
- box_plans: public SELECT
- box_subscriptions: users can SELECT/UPDATE their own
- shipments: users can SELECT their own
- box_contents: public SELECT

Service role full access to all tables.
```

> Pro tip: Add dispatch_offset_days to box_plans so different tiers can have different packing times. A premium plan might dispatch next-day while a standard plan dispatches 3 days after billing. This field drives the dispatch_date calculation in the webhook handler.

**Expected result:** All four tables are created with RLS. TypeScript types are generated. The box_plans table has the dispatch_offset_days column for each plan.

### 2. Build the plan comparison and subscription signup flow

Ask Lovable to create the plan selection page with comparison Cards and the subscription creation Edge Function.

```
Build a subscription plans page at src/pages/Plans.tsx.

Requirements:
- Fetch all is_active box_plans ordered by sort_order
- Display in a responsive grid as shadcn/ui Cards:
  - Plan image (box preview photo)
  - Plan name (large, font-bold)
  - 'Most Popular' Badge if is_featured=true, with accent border
  - Price per month as large text, below it 'per box'
  - Box value: 'Over ${box_value} in products'
  - Items per box: '{items_per_box} curated items'
  - Feature list with 3-4 bullets (free shipping, cancel anytime, etc.)
  - Subscribe Button (primary for featured, outline for others)
- If user already has an active subscription:
  - Their current plan's button shows 'Current Plan' (disabled)
  - Higher-value plans show 'Upgrade'
  - Lower plans show 'Downgrade'
- Subscribing opens a Dialog with two steps:
  Step 1: Shipping address form (react-hook-form + Zod): firstName, lastName, address1, address2, city, state, postalCode, country Select
  Step 2: Payment method form using Stripe Elements
- After payment method is collected, call create-box-subscription Edge Function
- Show next dispatch date after successful subscription: 'Your first box ships on {date}'
```

> Pro tip: Show the next box dispatch date in the subscription success message. Calculate it as today + plan.dispatch_offset_days, then format it as a friendly date. This sets subscriber expectations immediately and reduces 'where is my box?' support requests.

**Expected result:** The plans page renders with comparison cards. The subscription dialog collects address and payment. A successful subscription shows the next dispatch date.

### 3. Create the subscription billing Edge Functions

Build the Edge Functions for creating subscriptions and handling the Stripe billing webhook that creates shipments.

```
Create two Supabase Edge Functions:

1. supabase/functions/create-box-subscription/index.ts
- Accept: { planId, paymentMethodId, shippingAddress }
- Get authenticated user from JWT
- Look up box_plans by planId to get stripe_price_id and dispatch_offset_days
- Create Stripe customer with user email
- Attach payment method and set as default
- Create Stripe Subscription with stripe_price_id
- Insert into box_subscriptions: user_id, plan_id, stripe_subscription_id, stripe_customer_id, status='active', shipping_address, current_period_start, current_period_end
- Return: { subscriptionId, nextDispatchDate }
  (nextDispatchDate = current_period_start + dispatch_offset_days)

2. supabase/functions/box-billing-webhook/index.ts
- Verify Stripe webhook signature with constructEventAsync
- Handle invoice.paid:
  a. Get stripe_subscription_id from invoice.subscription
  b. Look up box_subscriptions in Supabase by stripe_subscription_id
  c. Look up the plan to get dispatch_offset_days
  d. Calculate dispatch_date = invoice.period_start date + dispatch_offset_days
  e. Insert into shipments: subscription_id, user_id, plan_id, stripe_invoice_id, status='created', dispatch_date, billing_period_start, billing_period_end
- Handle customer.subscription.updated: update box_subscriptions status
- Handle customer.subscription.deleted: update status='canceled'
- Return 200 for all events
```

> Pro tip: In the webhook handler, check if a shipment with the same stripe_invoice_id already exists before inserting. Use upsert with onConflict: 'stripe_invoice_id' to handle webhook retries safely without creating duplicate shipments.

**Expected result:** Creating a subscription in test mode works. A test invoice.paid webhook creates a shipment row with the calculated dispatch_date. The box_subscriptions table updates status on subscription lifecycle events.

### 4. Build the subscriber portal

Ask Lovable to create the subscriber-facing portal showing current subscription status, next shipment Calendar view, and shipment history.

```
Build a subscriber portal at src/pages/MySubscription.tsx.

Requirements:
- Fetch the current user's box_subscription joined with box_plans
- If no subscription: show a Card with 'You don't have an active subscription' and a Browse Plans Button

Top section - Subscription status:
- Plan name, status Badge (active=green, paused=yellow, canceled=gray)
- Current period Card: 'Current box period: {period_start} – {period_end}'
- Next billing date formatted as 'Next billing date: April 1, 2026'
- Monthly amount formatted as currency

Calendar section:
- shadcn/ui Calendar component (read-only) showing the current month
- Mark today with a dot indicator
- Mark the next dispatch_date with a highlighted cell and tooltip: 'Your box dispatches on this date'
- Mark the next billing date with a different color indicator

Current shipment section:
- Fetch the most recent shipment for this subscription
- Show status as a horizontal Step indicator: Created → Packed → Shipped → Delivered
- If tracking_number is set, show it with a copy button and a tracking_url link
- If status='shipped', show estimated delivery (shipped_at + 5 days)

Shipment history section:
- List past 6 shipments as compact rows with period, status Badge, and tracking link

Manage subscription buttons:
- 'Change Plan', 'Update Address', 'Pause Subscription', 'Cancel Subscription'
```

> Pro tip: The shadcn/ui Calendar component accepts a modifiers prop to highlight specific dates with custom styles. Pass dispatch dates and billing dates as separate modifier keys so they render with different colors on the calendar.

**Expected result:** The subscriber portal shows the subscription status, a calendar with highlighted dispatch and billing dates, and the current shipment status with tracking.

### 5. Build the admin fulfillment dashboard

Ask Lovable to build the admin-only fulfillment dashboard with a DataTable for managing shipments, bulk status updates, and a revenue chart.

```
Build an admin fulfillment dashboard at src/pages/admin/Fulfillment.tsx (admin-only route).

Requirements:
- Fetch all shipments ordered by dispatch_date ASC joined with box_plans and auth.users
- Status filter Tabs at top: All | Created | Packed | Shipped | Delivered
- Dispatch date filter: 'This week' | 'Next 30 days' | 'All upcoming'
- DataTable with columns:
  - Subscriber email
  - Plan name Badge
  - Dispatch date (highlighted red if past today and not shipped)
  - Status Badge with color coding
  - Tracking number (editable inline Input)
  - Carrier (editable Select: USPS/FedEx/UPS/DHL)
  - Actions: update status dropdown button
- Select multiple rows with checkboxes
- Bulk actions toolbar when rows selected: 'Mark as Packed', 'Mark as Shipped', 'Export CSV'
- When marking as Shipped, open a Dialog asking for tracking number and carrier (required)
- Summary Cards at top: Total This Week, Pending Dispatch, Shipped, Delivered
- Below the table: Recharts BarChart showing new subscribers per month for last 6 months
```

**Expected result:** The fulfillment dashboard shows all upcoming and recent shipments. Bulk marking rows as shipped opens the tracking number Dialog. The status filter tabs update the table. The subscriber chart shows monthly growth.

## Complete code example

File: `supabase/functions/box-billing-webhook/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import Stripe from 'https://esm.sh/stripe@14?target=deno'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
  apiVersion: '2023-10-16',
  httpClient: Stripe.createFetchHttpClient(),
})
const cryptoProvider = Stripe.createSubtleCryptoProvider()

serve(async (req: Request) => {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature') ?? ''

  let event: Stripe.Event
  try {
    event = await stripe.webhooks.constructEventAsync(
      body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '',
      undefined, cryptoProvider
    )
  } catch {
    return new Response('Signature failed', { status: 400 })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  if (event.type === 'invoice.paid') {
    const inv = event.data.object as Stripe.Invoice
    const stripeSubId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
    if (!stripeSubId) return new Response(JSON.stringify({ received: true }))

    const { data: sub } = await supabase
      .from('box_subscriptions')
      .select('id, user_id, plan_id, box_plans(dispatch_offset_days)')
      .eq('stripe_subscription_id', stripeSubId)
      .single()

    if (sub) {
      const plan = sub.box_plans as { dispatch_offset_days: number }
      const periodStart = new Date(inv.period_start * 1000)
      const dispatchDate = new Date(periodStart)
      dispatchDate.setDate(dispatchDate.getDate() + plan.dispatch_offset_days)

      await supabase.from('shipments').upsert({
        subscription_id: sub.id,
        user_id: sub.user_id,
        plan_id: sub.plan_id,
        stripe_invoice_id: inv.id,
        status: 'created',
        dispatch_date: dispatchDate.toISOString().split('T')[0],
        billing_period_start: periodStart.toISOString(),
        billing_period_end: new Date(inv.period_end * 1000).toISOString(),
      }, { onConflict: 'stripe_invoice_id' })
    }
  }

  if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') {
    const stripeSub = event.data.object as Stripe.Subscription
    await supabase.from('box_subscriptions').update({
      status: stripeSub.status === 'canceled' ? 'canceled' : stripeSub.status,
      cancel_at_period_end: stripeSub.cancel_at_period_end,
      current_period_end: new Date(stripeSub.current_period_end * 1000).toISOString(),
      updated_at: new Date().toISOString(),
    }).eq('stripe_subscription_id', stripeSub.id)
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 })
})
```

## Common mistakes

- **Creating shipment records when a subscription is created instead of when an invoice is paid** — Creating shipments at subscription creation assumes payment will succeed. If the first payment fails, you would have a shipment record for a subscriber who has not paid. Fix: Create shipment records only when invoice.paid fires in the webhook handler. Stripe sends invoice.paid for both the first invoice and all subsequent renewals. Upsert with stripe_invoice_id as the conflict key to handle webhook retries safely.
- **Not syncing subscription status changes to Supabase from webhooks** — If a subscriber cancels via the Stripe Customer Portal, the status change only exists in Stripe. Your app continues to show them as active and may create shipments for canceled subscriptions. Fix: Handle customer.subscription.updated and customer.subscription.deleted webhooks and update the box_subscriptions.status in Supabase accordingly. Always read subscription status from your Supabase table, never directly from Stripe in the frontend.
- **Calculating dispatch dates client-side on the fly** — If dispatch_offset_days changes between billing cycles, historical shipment dispatch dates would appear wrong if recalculated dynamically. Fix: Calculate and store dispatch_date in the shipments table at creation time (when the invoice.paid webhook fires). The stored date is permanent and reflects the offset that was in effect at that time, regardless of future changes to dispatch_offset_days.
- **Allowing subscribers to change their shipping address after the cutoff date** — If packing starts 3 days before dispatch and a subscriber changes their address on dispatch day, the label may already be printed with the old address. Fix: Add a shipping_address_locked_at column to shipments. When a shipment reaches packed status, lock address changes for that shipment. Show the cutoff date in the portal: 'Address changes for your next box are accepted until {cutoff_date}'.

## Best practices

- Create shipments only when invoice.paid fires — never at subscription creation. This ensures shipments only exist for paid billing cycles.
- Use upsert with stripe_invoice_id as the conflict key in the webhook handler to prevent duplicate shipments from webhook retries.
- Store dispatch_date at shipment creation time rather than calculating it dynamically. Stored dates are immutable and correct regardless of future plan changes.
- Display the next billing date and dispatch date prominently in the subscriber portal. Most subscription box support requests are 'when is my next box?'
- Implement a monthly box cutoff date visible to subscribers. After this date, changes to preferences and shipping address apply to the following month's box, not the current one.
- Track shipment status changes with timestamps (packed_at, shipped_at, delivered_at columns) rather than just the current status. This gives you fulfillment speed analytics.
- Show subscribers the retail value of their box and the savings versus buying individually. This reinforces the value perception and reduces churn.
- Use Stripe's Smart Retries for failed renewals. Configure the retry schedule in Stripe Dashboard → Settings → Billing to balance revenue recovery with subscriber experience.

## Frequently asked questions

### How do I synchronize billing cycles with my monthly dispatch schedule?

The most common approach is to anchor all subscriptions to a fixed billing date, or to accept that different subscribers have different billing dates and dispatch boxes rolling throughout the month. The webhook-driven approach in this guide handles rolling billing naturally — each invoice.paid event triggers a shipment with a dispatch_date calculated from that invoice's period_start plus the configured offset. For fixed monthly dispatch (e.g., all boxes ship on the 1st), set billing_cycle_anchor on Stripe subscriptions to the cutoff date.

### What happens if Stripe fails to collect payment for a renewal?

Stripe Smart Retries attempts the payment over several days. During retries, no invoice.paid event fires, so no new shipment is created — you do not ship unpaid boxes. If retries are exhausted, Stripe fires invoice.payment_failed and may update the subscription to past_due or unpaid. Your webhook updates box_subscriptions.status accordingly. Subscribers in past_due status see a payment update prompt in their portal.

### Can I offer different billing frequencies like quarterly or annual?

Yes. Create additional Stripe Prices with interval: quarter or interval: year and add them to your box_plans table. For annual subscriptions, your webhook creates 12 pending shipments at once (one per month) when the annual invoice is paid, or creates shipments monthly using a scheduled Edge Function that checks which annual subscribers are in an active billing period.

### How do I handle subscribers who want to skip a month?

Add a Skip button to the subscriber portal. Calling the Stripe API to pause the subscription for one cycle (pause_collection: { behavior: 'keep_as_draft', resumes_at: next_period_start }) skips billing for that period. No invoice.paid fires, so no shipment is created. Display the skipped period in the shipment history as a row with status 'Skipped'.

### Can I change box contents month-to-month?

Yes. The box_contents table links items to a billing_month column (e.g., 2026-04). Each month you add new content rows for the next month's box. The subscriber portal can show 'Next month's box' contents after a reveal date. The admin can manage monthly contents from a simple CRUD interface built on the box_contents table.

### How do I handle address validation for international subscribers?

Add a country Select to the shipping address form with a curated list of countries you ship to. For address validation, integrate with a carrier address validation API (FedEx, USPS, or a third-party service like EasyPost) in an Edge Function before saving the address. Display inline validation errors for invalid postal codes or addresses the carrier cannot deliver to.

### Is there help available for building a larger subscription box platform?

RapidDev builds production subscription box platforms in Lovable including inventory management, multi-warehouse fulfillment, and complex tier configurations. Contact us if you need a full-featured subscription commerce solution.

### How do I track customer lifetime value and churn for the subscription box?

Calculate LTV from your Supabase data: SUM of amount_paid from all paid invoices per subscriber. Calculate churn monthly as (subscribers who canceled this month) / (active subscribers at start of month). Build these as SQL aggregation functions and display on the admin dashboard. Recharts AreaCharts showing LTV distribution and monthly churn rate are the two most useful metrics for a subscription box business.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/subscription-box-service
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/subscription-box-service
