Skip to main content
RapidDev - Software Development Agency

How to Build a Digital Downloads Store with V0

Build a digital downloads store where creators sell PDFs, templates, and design assets with instant delivery after payment. V0 generates the storefront and product pages while Stripe handles checkout and a webhook creates purchase records with single-use download tokens that expire in 24 hours, preventing URL sharing.

What you'll build

  • Product grid storefront with preview images, pricing, and format badges
  • Stripe Checkout integration for one-time payments with product metadata
  • Secure file delivery using single-use download tokens with 24-hour expiry
  • Webhook-driven purchase flow that creates records only after confirmed payment
  • Creator sales dashboard with download counts and revenue tracking
  • Supabase Storage integration for file uploads with signed URLs
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate12 min read1-2 hoursV0 Premium or Free tier, Supabase free tier, Stripe accountLast updated April 2026RapidDev Engineering Team
TL;DR

Build a digital downloads store where creators sell PDFs, templates, and design assets with instant delivery after payment. V0 generates the storefront and product pages while Stripe handles checkout and a webhook creates purchase records with single-use download tokens that expire in 24 hours, preventing URL sharing.

Quick facts about this guide
FactValue
ToolV0
Build time1-2 hours
DifficultyIntermediate
Last updatedApril 2026

Project overview

Tech stack

V0 by Vercel (AI code generation)
Next.js 14+ (App Router)
TypeScript
Tailwind CSS + shadcn/ui
Supabase (PostgreSQL + Auth + Storage)
Stripe (Checkout + Webhooks)

Prerequisites

  • A V0 account (free or Premium)
  • A Supabase project with Storage enabled (create a private 'downloads' bucket)
  • A Stripe account with Checkout enabled
  • Basic familiarity with Next.js App Router and TypeScript

Build steps

1

Set Up the Supabase Schema and Storage Bucket

Create the database tables for products, purchases, and download tokens. Set up a private Supabase Storage bucket for the actual files. The download_tokens table stores unique tokens with expiry timestamps that are validated before generating signed URLs.

typescript
1-- Products
2create table products (
3 id uuid primary key default gen_random_uuid(),
4 creator_id uuid references auth.users not null,
5 title text not null,
6 description text,
7 price_cents int not null,
8 file_path text not null, -- path in Supabase Storage
9 preview_image_url text,
10 format text default 'pdf',
11 download_count int default 0,
12 is_active boolean default true,
13 created_at timestamptz default now()
14);
15
16-- Purchases
17create table purchases (
18 id uuid primary key default gen_random_uuid(),
19 user_id uuid references auth.users,
20 product_id uuid references products not null,
21 email text not null,
22 stripe_session_id text unique not null,
23 downloaded_at timestamptz,
24 created_at timestamptz default now()
25);
26
27-- Download tokens (single-use, time-limited)
28create table download_tokens (
29 id uuid primary key default gen_random_uuid(),
30 purchase_id uuid references purchases not null,
31 token text unique not null,
32 expires_at timestamptz not null,
33 used boolean default false
34);
35
36-- RLS
37alter table products enable row level security;
38alter table purchases enable row level security;
39alter table download_tokens enable row level security;
40
41-- Anyone can read active products
42create policy "Public read products" on products for select using (is_active = true);
43-- Creators manage their own products
44create policy "Creators manage products" on products for all using (auth.uid() = creator_id);
45-- Users read own purchases
46create policy "Users read purchases" on purchases for select using (auth.uid() = user_id);
47-- Download tokens readable by purchase owner
48create policy "Token access" on download_tokens for select
49 using (purchase_id in (select id from purchases where user_id = auth.uid()));
50
51-- Create private Storage bucket (run in Supabase Dashboard > Storage):
52-- Bucket name: downloads
53-- Public: OFF
54-- File size limit: 100MB
55-- Allowed MIME types: application/pdf, application/zip, image/*

Expected result: Database tables created with RLS. A private Storage bucket holds the actual files. Download tokens have unique constraints and expiry timestamps.

2

Build the Product Storefront and Detail Pages

Create the storefront grid showing all active products with preview images, titles, prices, and format badges. Each product links to a detail page with a full description and Buy button. Use generateMetadata for SEO.

typescript
1// app/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { Card, CardContent, CardFooter } from '@/components/ui/card';
4import { Badge } from '@/components/ui/badge';
5import { Button } from '@/components/ui/button';
6import Link from 'next/link';
7import Image from 'next/image';
8
9export default async function StorePage() {
10 const supabase = await createClient();
11
12 const { data: products } = await supabase
13 .from('products')
14 .select('*')
15 .eq('is_active', true)
16 .order('created_at', { ascending: false });
17
18 return (
19 <div className="max-w-6xl mx-auto p-6">
20 <h1 className="text-3xl font-bold mb-2">Digital Downloads</h1>
21 <p className="text-muted-foreground mb-8">Premium templates, guides, and design assets</p>
22
23 <div className="grid md:grid-cols-3 gap-6">
24 {products?.map((product) => (
25 <Link key={product.id} href={`/products/${product.id}`}>
26 <Card className="overflow-hidden hover:border-primary transition-colors">
27 {product.preview_image_url && (
28 <div className="aspect-[4/3] relative bg-muted">
29 <Image
30 src={product.preview_image_url}
31 alt={product.title}
32 fill
33 className="object-cover"
34 />
35 </div>
36 )}
37 <CardContent className="pt-4">
38 <h2 className="font-semibold">{product.title}</h2>
39 <p className="text-sm text-muted-foreground line-clamp-2 mt-1">
40 {product.description}
41 </p>
42 </CardContent>
43 <CardFooter className="flex items-center justify-between">
44 <span className="text-lg font-bold">
45 ${(product.price_cents / 100).toFixed(2)}
46 </span>
47 <Badge variant="secondary">{product.format.toUpperCase()}</Badge>
48 </CardFooter>
49 </Card>
50 </Link>
51 ))}
52 </div>
53 </div>
54 );
55}
56
57// app/products/[id]/page.tsx
58import { createClient } from '@/lib/supabase/server';
59import { Card, CardContent } from '@/components/ui/card';
60import { Badge } from '@/components/ui/badge';
61import { Skeleton } from '@/components/ui/skeleton';
62import { BuyButton } from './buy-button';
63import Image from 'next/image';
64import type { Metadata } from 'next';
65
66export async function generateMetadata({
67 params,
68}: {
69 params: Promise<{ id: string }>;
70}): Promise<Metadata> {
71 const { id } = await params;
72 const supabase = await createClient();
73 const { data: product } = await supabase.from('products').select('title, description').eq('id', id).single();
74 return {
75 title: product?.title || 'Product',
76 description: product?.description || '',
77 };
78}
79
80export default async function ProductPage({
81 params,
82}: {
83 params: Promise<{ id: string }>;
84}) {
85 const { id } = await params;
86 const supabase = await createClient();
87
88 const { data: product } = await supabase
89 .from('products')
90 .select('*')
91 .eq('id', id)
92 .single();
93
94 if (!product) return <div className="p-12 text-center">Product not found</div>;
95
96 return (
97 <div className="max-w-3xl mx-auto p-6">
98 <div className="grid md:grid-cols-2 gap-8">
99 <div className="aspect-square relative rounded-lg overflow-hidden bg-muted">
100 {product.preview_image_url ? (
101 <Image src={product.preview_image_url} alt={product.title} fill className="object-cover" />
102 ) : (
103 <Skeleton className="h-full w-full" />
104 )}
105 </div>
106 <div className="space-y-4">
107 <Badge variant="secondary">{product.format.toUpperCase()}</Badge>
108 <h1 className="text-2xl font-bold">{product.title}</h1>
109 <p className="text-muted-foreground">{product.description}</p>
110 <p className="text-3xl font-bold">${(product.price_cents / 100).toFixed(2)}</p>
111 <BuyButton productId={product.id} />
112 <p className="text-sm text-muted-foreground">
113 {product.download_count} downloads
114 </p>
115 </div>
116 </div>
117 </div>
118 );
119}

Expected result: A storefront grid showing all active products with images, prices, and format badges. Each product has a detail page with generateMetadata for SEO.

3

Create the Stripe Checkout Flow

Build the checkout API route that creates a Stripe Checkout session with the product price and metadata. The BuyButton client component calls this route and redirects to Stripe's hosted checkout page.

typescript
1// app/api/checkout/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { createClient } from '@/lib/supabase/server';
4import Stripe from 'stripe';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
7
8export async function POST(request: NextRequest) {
9 const { productId } = await request.json();
10 const supabase = await createClient();
11
12 const { data: product } = await supabase
13 .from('products')
14 .select('*')
15 .eq('id', productId)
16 .single();
17
18 if (!product) {
19 return NextResponse.json({ error: 'Product not found' }, { status: 404 });
20 }
21
22 // Get user if authenticated (optional for guest checkout)
23 const { data: { user } } = await supabase.auth.getUser();
24
25 const session = await stripe.checkout.sessions.create({
26 mode: 'payment',
27 line_items: [{
28 price_data: {
29 currency: 'usd',
30 product_data: {
31 name: product.title,
32 description: product.description || undefined,
33 images: product.preview_image_url ? [product.preview_image_url] : [],
34 },
35 unit_amount: product.price_cents,
36 },
37 quantity: 1,
38 }],
39 metadata: {
40 product_id: productId,
41 user_id: user?.id || '',
42 },
43 customer_email: user?.email || undefined,
44 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/download/{CHECKOUT_SESSION_ID}`,
45 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/products/${productId}`,
46 });
47
48 return NextResponse.json({ url: session.url });
49}
50
51// app/products/[id]/buy-button.tsx
52'use client';
53
54import { useState } from 'react';
55import { Button } from '@/components/ui/button';
56import { ShoppingCart } from 'lucide-react';
57
58export function BuyButton({ productId }: { productId: string }) {
59 const [loading, setLoading] = useState(false);
60
61 async function handleBuy() {
62 setLoading(true);
63 const res = await fetch('/api/checkout', {
64 method: 'POST',
65 headers: { 'Content-Type': 'application/json' },
66 body: JSON.stringify({ productId }),
67 });
68 const { url } = await res.json();
69 if (url) window.location.href = url;
70 setLoading(false);
71 }
72
73 return (
74 <Button onClick={handleBuy} disabled={loading} size="lg" className="w-full">
75 <ShoppingCart className="h-4 w-4 mr-2" />
76 {loading ? 'Redirecting...' : 'Buy Now'}
77 </Button>
78 );
79}

Expected result: Clicking Buy Now creates a Stripe Checkout session with product metadata and redirects to Stripe's hosted payment page.

4

Handle the Webhook and Generate Download Tokens

Create the Stripe webhook handler that listens for checkout.session.completed. It creates a purchase record, generates a unique download token with 24-hour expiry, and increments the product download count. Use request.text() for proper signature verification.

typescript
1// app/api/webhooks/stripe/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import Stripe from 'stripe';
4import { createClient } from '@supabase/supabase-js';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
7
8export async function POST(request: NextRequest) {
9 const body = await request.text();
10 const sig = request.headers.get('stripe-signature')!;
11
12 let event: Stripe.Event;
13 try {
14 event = stripe.webhooks.constructEvent(
15 body, sig, process.env.STRIPE_WEBHOOK_SECRET!
16 );
17 } catch (err) {
18 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
19 }
20
21 if (event.type === 'checkout.session.completed') {
22 const session = event.data.object as Stripe.Checkout.Session;
23 const { product_id, user_id } = session.metadata!;
24
25 // Use service role for webhook handler
26 const supabase = createClient(
27 process.env.NEXT_PUBLIC_SUPABASE_URL!,
28 process.env.SUPABASE_SERVICE_ROLE_KEY!
29 );
30
31 // Create purchase record
32 const { data: purchase } = await supabase
33 .from('purchases')
34 .insert({
35 product_id,
36 user_id: user_id || null,
37 email: session.customer_details?.email || '',
38 stripe_session_id: session.id,
39 })
40 .select()
41 .single();
42
43 if (purchase) {
44 // Generate single-use download token
45 const token = crypto.randomUUID().replace(/-/g, '');
46 const expiresAt = new Date();
47 expiresAt.setHours(expiresAt.getHours() + 24);
48
49 await supabase.from('download_tokens').insert({
50 purchase_id: purchase.id,
51 token,
52 expires_at: expiresAt.toISOString(),
53 });
54
55 // Increment download count
56 await supabase.rpc('increment_download_count', {
57 p_product_id: product_id,
58 });
59 }
60 }
61
62 return NextResponse.json({ received: true });
63}
64
65// Supabase function (run in SQL editor):
66// create or replace function increment_download_count(p_product_id uuid)
67// returns void as $$
68// begin
69// update products set download_count = download_count + 1
70// where id = p_product_id;
71// end;
72// $$ language plpgsql;

Expected result: Stripe webhook creates a purchase record and generates a unique download token with 24-hour expiry. The product download count increments automatically.

5

Build the Secure Download Route

Create the download route that validates the token, checks expiry, marks it as used, and generates a short-lived Supabase Storage signed URL for the actual file download.

typescript
1// app/download/[token]/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { createClient } from '@supabase/supabase-js';
4
5export async function GET(
6 request: NextRequest,
7 { params }: { params: Promise<{ token: string }> }
8) {
9 const { token } = await params;
10
11 const supabase = createClient(
12 process.env.NEXT_PUBLIC_SUPABASE_URL!,
13 process.env.SUPABASE_SERVICE_ROLE_KEY!
14 );
15
16 // Find and validate token
17 const { data: downloadToken } = await supabase
18 .from('download_tokens')
19 .select('*, purchases(product_id, products(title, file_path))')
20 .eq('token', token)
21 .single();
22
23 if (!downloadToken) {
24 return NextResponse.json({ error: 'Invalid download link' }, { status: 404 });
25 }
26
27 if (downloadToken.used) {
28 return NextResponse.json(
29 { error: 'This download link has already been used' },
30 { status: 410 }
31 );
32 }
33
34 if (new Date(downloadToken.expires_at) < new Date()) {
35 return NextResponse.json(
36 { error: 'This download link has expired' },
37 { status: 410 }
38 );
39 }
40
41 // Mark token as used
42 await supabase
43 .from('download_tokens')
44 .update({ used: true })
45 .eq('id', downloadToken.id);
46
47 // Update purchase downloaded_at
48 await supabase
49 .from('purchases')
50 .update({ downloaded_at: new Date().toISOString() })
51 .eq('id', downloadToken.purchase_id);
52
53 // Generate 60-second signed URL from private Storage bucket
54 const filePath = downloadToken.purchases.products.file_path;
55 const { data: signedUrl } = await supabase.storage
56 .from('downloads')
57 .createSignedUrl(filePath, 60);
58
59 if (!signedUrl?.signedUrl) {
60 return NextResponse.json({ error: 'File not found' }, { status: 500 });
61 }
62
63 // Redirect to signed URL
64 return NextResponse.redirect(signedUrl.signedUrl);
65}
66
67// app/download/[sessionId]/page.tsx (success page after checkout)
68import { createClient } from '@supabase/supabase-js';
69import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
70import { Button } from '@/components/ui/button';
71import { Download, CheckCircle } from 'lucide-react';
72
73export default async function DownloadPage({
74 params,
75}: {
76 params: Promise<{ sessionId: string }>;
77}) {
78 const { sessionId } = await params;
79
80 const supabase = createClient(
81 process.env.NEXT_PUBLIC_SUPABASE_URL!,
82 process.env.SUPABASE_SERVICE_ROLE_KEY!
83 );
84
85 // Find purchase by Stripe session ID
86 const { data: purchase } = await supabase
87 .from('purchases')
88 .select('*, products(title), download_tokens(token, expires_at, used)')
89 .eq('stripe_session_id', sessionId)
90 .single();
91
92 if (!purchase) {
93 return <div className="p-12 text-center">Purchase not found. Please check your email.</div>;
94 }
95
96 const token = purchase.download_tokens?.[0];
97
98 return (
99 <div className="max-w-md mx-auto p-6">
100 <Card>
101 <CardHeader className="text-center">
102 <CheckCircle className="h-12 w-12 text-green-500 mx-auto" />
103 <CardTitle className="mt-2">Payment Successful</CardTitle>
104 </CardHeader>
105 <CardContent className="text-center space-y-4">
106 <p className="text-muted-foreground">
107 Thank you for purchasing {purchase.products.title}.
108 </p>
109 {token && !token.used ? (
110 <Button asChild size="lg" className="w-full">
111 <a href={`/download/${token.token}`}>
112 <Download className="h-4 w-4 mr-2" /> Download Now
113 </a>
114 </Button>
115 ) : (
116 <p className="text-sm text-muted-foreground">Your download link has been used.</p>
117 )}
118 <p className="text-xs text-muted-foreground">
119 This link expires in 24 hours and can only be used once.
120 </p>
121 </CardContent>
122 </Card>
123 </div>
124 );
125}

Expected result: The download route validates the token, marks it as used, and redirects to a 60-second Supabase Storage signed URL. Expired or used tokens return appropriate error responses.

Complete code

app/download/[token]/route.ts
1import { NextRequest, NextResponse } from 'next/server';
2import { createClient } from '@supabase/supabase-js';
3
4export async function GET(
5 request: NextRequest,
6 { params }: { params: Promise<{ token: string }> }
7) {
8 const { token } = await params;
9 const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
10
11 const { data: downloadToken } = await supabase
12 .from('download_tokens')
13 .select('*, purchases(product_id, products(title, file_path))')
14 .eq('token', token)
15 .single();
16
17 if (!downloadToken) {
18 return NextResponse.json({ error: 'Invalid download link' }, { status: 404 });
19 }
20 if (downloadToken.used) {
21 return NextResponse.json({ error: 'This download link has already been used' }, { status: 410 });
22 }
23 if (new Date(downloadToken.expires_at) < new Date()) {
24 return NextResponse.json({ error: 'This download link has expired' }, { status: 410 });
25 }
26
27 await supabase.from('download_tokens').update({ used: true }).eq('id', downloadToken.id);
28 await supabase.from('purchases').update({ downloaded_at: new Date().toISOString() }).eq('id', downloadToken.purchase_id);
29
30 const filePath = downloadToken.purchases.products.file_path;
31 const { data: signedUrl } = await supabase.storage.from('downloads').createSignedUrl(filePath, 60);
32
33 if (!signedUrl?.signedUrl) {
34 return NextResponse.json({ error: 'File not found' }, { status: 500 });
35 }
36
37 return NextResponse.redirect(signedUrl.signedUrl);
38}

Customization ideas

Common pitfalls

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Best practices

  • Store digital files in a private Supabase Storage bucket and serve them only through validated download tokens
  • Use single-use tokens with 24-hour expiry to prevent URL sharing while giving customers time to download
  • Generate a 60-second Supabase Storage signed URL for the actual file delivery to minimize the sharing window
  • Always use request.text() for Stripe webhook body parsing to preserve raw body for signature verification
  • Use the service role key in webhook handlers since they run outside user auth context
  • Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without the NEXT_PUBLIC_ prefix
  • Pass the product_id in Stripe Checkout metadata so the webhook knows which product was purchased

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I need a Next.js App Router digital downloads store with: 1) Product grid storefront with images and prices 2) Stripe Checkout for payments 3) Webhook handler that creates purchase records and generates single-use download tokens with 24-hour expiry 4) Secure download route that validates tokens and generates Supabase Storage signed URLs. Use shadcn/ui Card, Badge, Button with TypeScript.

Build Prompt

Create a Next.js API route handler for Stripe checkout.session.completed webhooks that: 1) Uses request.text() for body parsing 2) Verifies webhook signature 3) Creates a purchase record in Supabase 4) Generates a unique download token with 24-hour expiry 5) Increments the product download count. Use the Supabase service role key since webhooks run outside user context.

Frequently asked questions

How do download tokens prevent URL sharing?

Each purchase generates a unique token stored in the download_tokens table with used=false and a 24-hour expires_at. When someone visits the download URL, the route checks: 1) token exists, 2) used is false, 3) expires_at is in the future. If all pass, it marks used=true and generates a 60-second Supabase Storage signed URL. This means each token works exactly once, and the actual file URL expires in 60 seconds.

Why use a private Supabase Storage bucket?

A private bucket means files cannot be accessed by their direct URL. The only way to get the file is through a signed URL generated server-side with the service role key. This ensures every download goes through your token validation logic, preventing unauthorized access even if someone knows the file path.

Why use request.text() instead of request.json() for Stripe webhooks?

Stripe webhook signature verification requires the exact raw request body as a string. request.json() parses the JSON and changes the formatting, which invalidates the HMAC signature. request.text() preserves the body exactly as Stripe sent it, which is required for constructEvent to verify the signature.

How does the Stripe Checkout flow work?

When a customer clicks Buy Now, the client sends a POST to /api/checkout with the product ID. The API route creates a Stripe Checkout session with the product price and metadata (product_id, user_id), then returns the session URL. The client redirects to Stripe's hosted checkout page. After payment, Stripe fires a checkout.session.completed webhook to /api/webhooks/stripe, which creates the purchase and download token.

Can customers download more than once if the token is used?

With the default single-use token, no. To allow multiple downloads, you can either increase the token count per purchase (generate multiple tokens) or change the logic to create a new token when the customer visits a re-download page and verifies ownership through their authenticated session.

Where should I store the Stripe API keys?

Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without the NEXT_PUBLIC_ prefix since they are used only in API routes and webhook handlers. After deploying, register the production webhook URL in Stripe Dashboard pointing to /api/webhooks/stripe and copy the signing secret.

Can RapidDev help build a custom digital product platform?

Yes. RapidDev has helped over 600 creators and businesses build digital product stores with features like license key management, subscription libraries, and affiliate programs. Book a free consultation at RapidDev to discuss your platform requirements.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

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.