# How to Add a Digital Asset Marketplace to Your App — Copy-Paste Prompts Inside

- Tool: App Features
- Last updated: July 2026

## TL;DR

A digital asset marketplace needs Supabase Storage (private bucket, signed download URLs), Stripe Connect Express for seller payouts and platform commission splits, and an assets table with a purchases table that gates download access. With Lovable or V0 you can reach a working marketplace in 1–2 days. Expect $0–$80/month running cost up to 1,000 users, scaling to $100–$400/month at 10,000 with heavy file egress.

## What a Digital Asset Marketplace Actually Is

A digital asset marketplace lets creators upload files — templates, fonts, music loops, Figma assets, code snippets — set a price, and receive payouts when buyers purchase. The platform earns a commission on each transaction. Three technical problems make this feature Advanced difficulty relative to a standard storefront: First, download links must be cryptographically gated (signed URLs that expire) so buyers can't share or re-use links. Second, Stripe Connect Express must be onboarded per seller before any payout can be issued — it's a multi-step external flow, not a simple API call. Third, RLS policies must correctly separate what sellers see (their own assets and earnings) from what buyers see (only their purchased downloads). The 70% problem is real here — AI tools get the storefront and upload form right, but seller payouts and signed-URL enforcement need explicit prompting.

## Anatomy of the Feature

Six components span the buyer journey and the seller backend. AI tools handle the UI well; the signed URL layer and Stripe Connect payout chain are where explicit prompting is essential.

- **Asset upload form** (ui): A multi-step wizard: step 1 is file upload (drag-and-drop with progress bar) to a private Supabase Storage bucket; step 2 is metadata (title, description, tags, price in cents, license type dropdown); step 3 is a preview confirmation. Built with shadcn/ui Stepper or a custom multi-step form with react-hook-form and zod validation.
- **Supabase Storage + signed URL gate** (backend): The paid asset file lives in a private Supabase Storage bucket. After payment verification, a server-side function calls supabase.storage.from('assets').createSignedUrl(file_path, 3600) to generate a 60-minute expiring URL. This URL is returned to the buyer and recorded in purchases.downloaded_at. The URL cannot be used after expiry and carries no persistent public path.
- **Stripe Connect payment flow** (service): Stripe Connect Express handles seller onboarding (external Stripe-hosted flow) and automatic payouts. Each payment intent is created with application_fee_amount set to the platform commission (e.g., 20% of price_cents). Stripe automatically transfers the seller's portion to their Connect account on each charge.
- **Assets and purchases tables** (data): The assets table stores file_path (private path in Supabase Storage), preview_url (public watermarked path), price_cents, license_type, tags (text[]), and download_count. The purchases table records buyer_id, asset_id, stripe_payment_intent_id, and downloaded_at. RLS on purchases restricts rows to the buyer. RLS on assets allows public SELECT for browse but restricts UPDATE/DELETE to the seller.
- **Payment webhook handler** (backend): A Supabase Edge Function or Next.js API route receives Stripe's payment_intent.succeeded webhook, verifies the signature using the raw request body and constructEventAsync(), inserts the purchase record, increments download_count atomically, and updates the seller's pending earnings in the sellers table.
- **Seller dashboard** (ui): A protected /seller page showing total earnings, pending payout balance fetched from the Stripe Connect API (stripe.accounts.retrieve(seller.stripe_account_id)), sales count, and a Recharts bar chart of sales over the last 30 days grouped by week. Asset management table with edit title/price and unpublish toggle.

## Data model

Four tables power the marketplace: sellers (Stripe Connect linkage), assets, purchases, and reviews. Paste this schema into the Supabase SQL editor before prompting your AI tool.

```sql
-- Seller Stripe Connect accounts
create table public.sellers (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null unique,
  stripe_account_id text,
  payout_enabled boolean not null default false,
  created_at timestamptz not null default now()
);

alter table public.sellers enable row level security;

create policy "Sellers manage own record"
  on public.sellers for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Digital assets
create table public.assets (
  id uuid primary key default gen_random_uuid(),
  seller_id uuid references public.sellers(id) on delete cascade not null,
  title text not null,
  description text not null,
  price_cents int not null check (price_cents >= 50),
  currency text not null default 'usd',
  file_path text not null,       -- private Supabase Storage path
  preview_url text not null,     -- public watermarked preview
  license_type text not null check (license_type in ('personal','commercial','extended')),
  tags text[] not null default '{}',
  download_count int not null default 0,
  rating_avg numeric(3,2),
  is_published boolean not null default true,
  created_at timestamptz not null default now()
);

alter table public.assets enable row level security;

create policy "Anyone can browse published assets"
  on public.assets for select
  using (is_published = true);

create policy "Sellers manage own assets"
  on public.assets for all
  using (
    seller_id in (
      select id from public.sellers where user_id = auth.uid()
    )
  );

create index assets_search_idx on public.assets using gin(to_tsvector('english', title || ' ' || description));
create index assets_tags_idx on public.assets using gin(tags);

-- Purchases (grant download access)
create table public.purchases (
  id uuid primary key default gen_random_uuid(),
  buyer_id uuid references auth.users(id) on delete set null,
  asset_id uuid references public.assets(id) on delete restrict not null,
  stripe_payment_intent_id text not null unique,
  amount_paid int not null,
  downloaded_at timestamptz,
  download_count int not null default 0,
  created_at timestamptz not null default now()
);

alter table public.purchases enable row level security;

create policy "Buyers see own purchases"
  on public.purchases for select
  using (auth.uid() = buyer_id);

create policy "Sellers see purchases of their assets"
  on public.purchases for select
  using (
    asset_id in (
      select a.id from public.assets a
      join public.sellers s on s.id = a.seller_id
      where s.user_id = auth.uid()
    )
  );

-- Reviews (buyer-written, post-purchase)
create table public.reviews (
  id uuid primary key default gen_random_uuid(),
  buyer_id uuid references auth.users(id) on delete set null not null,
  asset_id uuid references public.assets(id) on delete cascade not null,
  rating int not null check (rating between 1 and 5),
  body text,
  created_at timestamptz not null default now(),
  constraint reviews_unique_buyer_asset unique (buyer_id, asset_id)
);

alter table public.reviews enable row level security;

create policy "Buyers write own reviews"
  on public.reviews for insert
  with check (auth.uid() = buyer_id);

create policy "Anyone reads reviews"
  on public.reviews for select using (true);

-- Atomic download count increment (call via supabase.rpc)
create or replace function increment_download_count(asset_id uuid)
returns void language sql security definer as $$
  update public.assets set download_count = download_count + 1 where id = asset_id;
$$;
```

The increment_download_count RPC function ensures atomic increments that survive concurrent purchases. Call it via supabase.rpc('increment_download_count', { asset_id: '...' }) from your webhook handler instead of a read-then-write pattern.

## Build paths

### Lovable — fit 4/10, 1–2 days

Best AI tool for the full marketplace — generates Supabase schema, Edge Functions for Stripe Connect and signed URL download, and seller dashboard UI in a connected project.

1. Create a new Lovable project, connect Lovable Cloud, and open the Cloud tab to confirm Supabase auth is provisioned
2. Paste the prompt below; let Agent Mode build the asset upload wizard, marketplace browse page, checkout flow, and seller dashboard
3. In Lovable Cloud > Secrets, add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_CONNECT_CLIENT_ID before testing payment flows
4. Publish to your live URL and complete a test Stripe Connect Express onboarding in a full browser window (not the preview) — the Stripe-hosted onboarding flow breaks inside iframes

Starter prompt:

```
Build a digital asset marketplace. Sellers upload files to a PRIVATE Supabase Storage bucket called 'assets'; a separate PUBLIC bucket 'previews' stores watermarked thumbnails. Buyers browse published assets, click Buy, and pay via Stripe. Platform commission is 20% — set application_fee_amount = Math.round(price_cents * 0.20) when creating the Payment Intent. Use Stripe Connect Express for seller payouts: generate an account link via stripe.accountLinks.create() and redirect the seller to complete onboarding in a full page redirect (not a popup). After payment, verify purchase in an Edge Function webhook handler by checking purchases table for existing stripe_payment_intent_id (idempotency). On confirmed purchase: insert purchase row, call increment_download_count RPC on assets table. Download endpoint: Server Action or Edge Function verifies auth.uid() matches purchases.buyer_id and asset_id, then calls supabase.storage.from('assets').createSignedUrl(asset.file_path, 3600) — return the signed URL to client, limit 5 downloads per purchase (track in purchases.download_count). Seller dashboard at /seller: show total earnings, Recharts bar chart of weekly sales for last 30 days, asset table with title, sales count, and unpublish toggle. Asset browse page: full-text search using Supabase ilike on title and tags, filter by license_type and price range, show preview_url image, rating_avg, download_count. License selector on product page: Personal / Commercial / Extended. RLS: buyers can only access assets they purchased; sellers can only edit their own assets; never expose file_path to the client. Use raw body for Stripe webhook signature verification via constructEventAsync().
```

Limitations:

- Stripe Connect Express onboarding must open in a full browser redirect — it fails inside the Lovable preview iframe; test only on the published URL
- Complex seller payout logic (escrow, delayed releases, platform fee adjustments) likely needs manual Edge Function review after generation
- The 70% problem appears most often on the seller dashboard payout balance — Stripe Connect API calls may need manual wiring after Lovable generates the UI

### V0 — fit 4/10, 1–2 days

Excellent for the buyer-facing marketplace UI (product grid, checkout, download flow) and the seller dashboard with Recharts — Next.js API routes handle Stripe Connect and Server Actions gate downloads.

1. Paste the prompt below; V0 generates the marketplace browse page, asset detail page, checkout Server Action, and seller dashboard with Recharts
2. Add environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_CONNECT_CLIENT_ID
3. Run the SQL schema from this page in the Supabase SQL editor
4. Deploy to Vercel — Stripe webhooks and Stripe Connect onboarding require the live deployment URL, not the V0 sandbox

Starter prompt:

```
Build a digital asset marketplace in Next.js 14 App Router using Supabase and Stripe Connect. Asset listing page at /marketplace: server component fetching published assets from Supabase with full-text search (ilike on title and tags), filter by license_type (personal/commercial/extended) and price range; show preview_url image, title, price, rating_avg, download_count. Asset detail page at /marketplace/[id]: show full description, license terms, sample preview image, buy button. Buy flow: Server Action creates Stripe Payment Intent with application_fee_amount = 20% of price_cents for Stripe Connect; redirect buyer to Stripe Checkout. Webhook API route at /api/webhooks/stripe: read raw body, call constructEventAsync(), on payment_intent.succeeded insert purchase row (idempotency: check existing stripe_payment_intent_id), call supabase.rpc('increment_download_count', { asset_id }). Download Server Action: verify purchases row for current user + asset_id, check download_count < 5, generate signed URL via supabase.storage.from('assets').createSignedUrl(file_path, 3600), increment downloads.download_count, return signed URL. Seller onboarding page /seller/onboarding: Server Action calls stripe.accountLinks.create() with type 'account_onboarding' and return_url, redirects to Stripe-hosted flow. Seller dashboard /seller: Recharts BarChart of weekly sales last 30 days, total earnings, pending payout (from stripe.accounts.retrieve), asset list with unpublish toggle. Never expose file_path to client. RLS as per schema — buyers see only own purchases.
```

Limitations:

- No auto-provisioning — Stripe and Supabase must be wired manually in Vercel environment variables
- SSR for purchase-gated download pages needs careful Server Action design to avoid exposing file_path before purchase verification
- Vercel's 10-second serverless function timeout can hit on Stripe Connect onboarding API calls under cold starts — consider Edge Runtime for that route

### Custom — fit 5/10, 3–6 weeks

Required when you need escrow with configurable holding periods, DMCA takedown workflows, multi-file bundles, affiliate commission tracking, or subscription-based library access alongside per-asset purchase.

1. Design a multi-vendor escrow layer: hold seller funds in Stripe for a configurable period (e.g., 14 days) before auto-transfer using Stripe Connect delayed transfers
2. Build a DMCA workflow: reports table, seller notification email with 72-hour response window, auto-unpublish on expiry of window, admin review queue
3. Add bundle support: bundles table (id, seller_id, name, price_cents), bundle_assets join table; signed URL generation loops over all asset file_paths in the bundle
4. Wire affiliate tracking: referral_codes table, utm_source tracking on checkout, affiliate commission Edge Function triggered by payment_intent.succeeded

Limitations:

- Stripe Connect compliance review can add 2–5 business days before the platform can process live payments
- Complex RLS policies for bundles, affiliate tables, and DMCA state require careful testing — a missing policy is a security hole, not a build error

## Gotchas

- **Permanent public download URL instead of signed URL** — AI tools often generate download links using the public Supabase Storage URL pattern (e.g., https://[project].supabase.co/storage/v1/object/public/assets/[path]). Anyone who obtains this URL — from browser DevTools, a shared screenshot, or a buyer sharing the link — can download the file indefinitely without paying. Fix: Store asset files in a PRIVATE Supabase Storage bucket. Generate download links server-side only via supabase.storage.from('assets').createSignedUrl(file_path, 3600) after verifying a valid purchase record. Never expose file_path to the client in any API response. Explicitly state 'signed URL expiring in 60 minutes, not a public URL' in your prompt.
- **Stripe Connect onboarding blocked inside iframe** — Stripe Express onboarding generates an external Stripe-hosted URL. When opened inside the Lovable preview iframe, the Stripe page detects the iframe context and either blocks the flow entirely or breaks the OAuth handshake, leaving sellers stuck at a blank screen. Fix: Stripe Connect onboarding must open as a full browser navigation — either window.location.href = accountLinkUrl or window.open(accountLinkUrl, '_self'). Test exclusively on the published Lovable URL or deployed Vercel URL, never in the preview.
- **Seller not linked to Stripe account when first asset goes on sale** — Lovable or V0 may generate the asset upload flow independently of the seller onboarding flow. A seller can publish assets before completing Stripe Connect onboarding. When a buyer purchases, Stripe has no destination for the platform's application_fee transfer and the payment fails or the payout is lost. Fix: Before allowing asset publishing (is_published = true), check sellers.payout_enabled = true. Show a 'Complete your payout setup' banner that links to the Stripe Connect onboarding flow if payout_enabled is false. Block the 'Publish asset' button at the UI level and enforce it in the Server Action.
- **Overly permissive RLS on purchases table exposes buyer history** — AI-generated RLS on the purchases table often uses a single SELECT policy that allows any authenticated user to see all rows. This lets any buyer query purchases for any asset and see other buyers' purchase records, payment intent IDs, and download counts. Fix: Apply two separate RLS SELECT policies: one for buyers (WHERE buyer_id = auth.uid()) and one for sellers (WHERE asset_id in (SELECT id FROM assets WHERE seller_id IN (SELECT id FROM sellers WHERE user_id = auth.uid()))). These must be separate policies — a single permissive policy covers both, which is the dangerous default.
- **Non-atomic download_count increment creates race conditions** — AI tools generate download count increments as read-then-write: SELECT download_count, then UPDATE SET download_count = [count + 1]. Under concurrent purchases — common on popular assets — two webhook handler invocations read the same count and both write the same incremented value, causing one sale to go uncounted. Fix: Use the supabase.rpc('increment_download_count', { asset_id }) function from the data model above, which executes a single atomic UPDATE SET download_count = download_count + 1 in SQL. Never read the count in application code before incrementing.

## Best practices

- Always store paid assets in a PRIVATE Supabase Storage bucket — public buckets turn every download link into a permanent free access URL
- Check sellers.payout_enabled before allowing asset publishing — a payment that cannot payout is worse than no payment at all
- Rate-limit the download endpoint per purchase (maximum 5 downloads per purchase record) to prevent one purchase from serving unlimited distribution
- Use Stripe webhook idempotency keys and check for existing stripe_payment_intent_id before inserting a purchase — Stripe retries webhooks on 5xx and creates duplicate purchase records without this check
- Add full-text search from day one using Supabase's built-in pg_search or GIN indexes on title and tags — text search added later requires a schema migration on a table that may already have thousands of assets
- Display pending vs. paid payout separately in the seller dashboard — sellers need to know what is in transit to Stripe and what has cleared, not just a total earnings number
- Serve preview images from a public bucket with watermarking rather than downscaling the original — watermarks are easier to enforce and visually signal purchase is needed

## Frequently asked questions

### Do I need Stripe Connect or can I use regular Stripe?

You need Stripe Connect if sellers are different people or businesses who receive payouts from your platform. Regular Stripe (without Connect) deposits all funds into one account — yours. If you are the only seller, regular Stripe is fine. For a true marketplace where third-party creators upload and sell their own work, Stripe Connect Express is the right product — it handles seller KYC, tax forms, and automatic payout splits via application_fee_amount.

### How do I prevent buyers from sharing download links?

Generate signed Supabase Storage URLs server-side that expire after 60 minutes, and limit download count to a maximum of 5 per purchase record. The expiring URL means a shared link becomes worthless after an hour. The download count limit catches automated scrapers that download once and redistribute. Store the signed URL request timestamp in purchases.downloaded_at so you can audit unusual download patterns.

### What is the platform fee and how does it work?

When you create a Stripe Payment Intent for a marketplace purchase, you set application_fee_amount equal to your desired platform cut (e.g., 20% of price_cents). Stripe collects the full payment from the buyer, retains your fee, and transfers the remainder to the seller's Stripe Connect account automatically. You control the percentage — common marketplace rates are 10–30% depending on the asset category.

### How do sellers get paid — instantly or after a delay?

By default, Stripe Connect uses a 2-business-day rolling payout schedule to the seller's bank account after each charge settles. You can configure this to instant payouts (Stripe Instant Payouts, available in select countries for a fee) or delayed payouts (escrow model, where you hold funds for a set period before releasing). The holding period is configured on your Stripe Connect account settings.

### How do I handle refunds when the asset is already downloaded?

Digital asset refunds are a policy decision, not a technical one. Technically, you call stripe.refunds.create({ payment_intent: id }) and set the purchases row status to 'refunded', which your download gate checks before generating a signed URL. Most digital marketplaces have a strict no-refund policy for downloaded items, with exceptions for assets that fail to open or differ materially from the preview. Define this policy in your terms before launch.

### Can I sell bundles of multiple files?

Not with the basic schema above. Bundles require a bundles table (id, seller_id, name, price_cents) and a bundle_assets join table linking bundle_id to asset_id. The download endpoint then generates signed URLs for every asset in the bundle. This is an extension of the base schema — add it explicitly to your prompt if bundles are a day-one requirement, otherwise add it as a follow-up feature after the single-asset flow is validated.

### How do I protect assets from piracy?

Signed expiring URLs stop casual link sharing. For higher-value assets (premium fonts, music licenses, commercial templates), embed buyer metadata in the downloaded file using a watermark or invisible steganography — this traces leaked copies back to the specific buyer. The most practical approach for most marketplaces is: expiring signed URLs, download count limits, clear license terms with legal recourse, and DMCA takedown compliance.

### Do I need to handle sales tax on digital goods?

Yes, in many jurisdictions. Digital goods are taxable in the EU (VAT MOSS), UK (VAT), Australia (GST), and an increasing number of US states. Stripe Tax (an add-on, approximately $0.50 per transaction) automates tax calculation and collection at checkout based on buyer location. For a marketplace, the tax obligation typically falls on the platform, not the seller. Add Stripe Tax to your prompt if you expect international buyers.

---

Source: https://www.rapidevelopers.com/app-features/digital-asset-marketplace
© RapidDev — https://www.rapidevelopers.com/app-features/digital-asset-marketplace
