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.
| Fact | Value |
|---|---|
| Tool | V0 |
| Build time | 1-2 hours |
| Difficulty | Intermediate |
| Last updated | April 2026 |
Project overview
Tech stack
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
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.
1-- Products2create 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 Storage9 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);1516-- Purchases17create 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);2627-- 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 false34);3536-- RLS37alter table products enable row level security;38alter table purchases enable row level security;39alter table download_tokens enable row level security;4041-- Anyone can read active products42create policy "Public read products" on products for select using (is_active = true);43-- Creators manage their own products44create policy "Creators manage products" on products for all using (auth.uid() = creator_id);45-- Users read own purchases46create policy "Users read purchases" on purchases for select using (auth.uid() = user_id);47-- Download tokens readable by purchase owner48create policy "Token access" on download_tokens for select49 using (purchase_id in (select id from purchases where user_id = auth.uid()));5051-- Create private Storage bucket (run in Supabase Dashboard > Storage):52-- Bucket name: downloads53-- Public: OFF54-- File size limit: 100MB55-- 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.
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.
1// app/page.tsx2import { 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';89export default async function StorePage() {10 const supabase = await createClient();1112 const { data: products } = await supabase13 .from('products')14 .select('*')15 .eq('is_active', true)16 .order('created_at', { ascending: false });1718 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>2223 <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 <Image30 src={product.preview_image_url}31 alt={product.title}32 fill33 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}5657// app/products/[id]/page.tsx58import { 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';6566export 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}7980export default async function ProductPage({81 params,82}: {83 params: Promise<{ id: string }>;84}) {85 const { id } = await params;86 const supabase = await createClient();8788 const { data: product } = await supabase89 .from('products')90 .select('*')91 .eq('id', id)92 .single();9394 if (!product) return <div className="p-12 text-center">Product not found</div>;9596 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} downloads114 </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.
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.
1// app/api/checkout/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { createClient } from '@/lib/supabase/server';4import Stripe from 'stripe';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);78export async function POST(request: NextRequest) {9 const { productId } = await request.json();10 const supabase = await createClient();1112 const { data: product } = await supabase13 .from('products')14 .select('*')15 .eq('id', productId)16 .single();1718 if (!product) {19 return NextResponse.json({ error: 'Product not found' }, { status: 404 });20 }2122 // Get user if authenticated (optional for guest checkout)23 const { data: { user } } = await supabase.auth.getUser();2425 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 });4748 return NextResponse.json({ url: session.url });49}5051// app/products/[id]/buy-button.tsx52'use client';5354import { useState } from 'react';55import { Button } from '@/components/ui/button';56import { ShoppingCart } from 'lucide-react';5758export function BuyButton({ productId }: { productId: string }) {59 const [loading, setLoading] = useState(false);6061 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 }7273 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.
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.
1// app/api/webhooks/stripe/route.ts2import { NextRequest, NextResponse } from 'next/server';3import Stripe from 'stripe';4import { createClient } from '@supabase/supabase-js';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);78export async function POST(request: NextRequest) {9 const body = await request.text();10 const sig = request.headers.get('stripe-signature')!;1112 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 }2021 if (event.type === 'checkout.session.completed') {22 const session = event.data.object as Stripe.Checkout.Session;23 const { product_id, user_id } = session.metadata!;2425 // Use service role for webhook handler26 const supabase = createClient(27 process.env.NEXT_PUBLIC_SUPABASE_URL!,28 process.env.SUPABASE_SERVICE_ROLE_KEY!29 );3031 // Create purchase record32 const { data: purchase } = await supabase33 .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();4243 if (purchase) {44 // Generate single-use download token45 const token = crypto.randomUUID().replace(/-/g, '');46 const expiresAt = new Date();47 expiresAt.setHours(expiresAt.getHours() + 24);4849 await supabase.from('download_tokens').insert({50 purchase_id: purchase.id,51 token,52 expires_at: expiresAt.toISOString(),53 });5455 // Increment download count56 await supabase.rpc('increment_download_count', {57 p_product_id: product_id,58 });59 }60 }6162 return NextResponse.json({ received: true });63}6465// Supabase function (run in SQL editor):66// create or replace function increment_download_count(p_product_id uuid)67// returns void as $$68// begin69// update products set download_count = download_count + 170// 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.
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.
1// app/download/[token]/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { createClient } from '@supabase/supabase-js';45export async function GET(6 request: NextRequest,7 { params }: { params: Promise<{ token: string }> }8) {9 const { token } = await params;1011 const supabase = createClient(12 process.env.NEXT_PUBLIC_SUPABASE_URL!,13 process.env.SUPABASE_SERVICE_ROLE_KEY!14 );1516 // Find and validate token17 const { data: downloadToken } = await supabase18 .from('download_tokens')19 .select('*, purchases(product_id, products(title, file_path))')20 .eq('token', token)21 .single();2223 if (!downloadToken) {24 return NextResponse.json({ error: 'Invalid download link' }, { status: 404 });25 }2627 if (downloadToken.used) {28 return NextResponse.json(29 { error: 'This download link has already been used' },30 { status: 410 }31 );32 }3334 if (new Date(downloadToken.expires_at) < new Date()) {35 return NextResponse.json(36 { error: 'This download link has expired' },37 { status: 410 }38 );39 }4041 // Mark token as used42 await supabase43 .from('download_tokens')44 .update({ used: true })45 .eq('id', downloadToken.id);4647 // Update purchase downloaded_at48 await supabase49 .from('purchases')50 .update({ downloaded_at: new Date().toISOString() })51 .eq('id', downloadToken.purchase_id);5253 // Generate 60-second signed URL from private Storage bucket54 const filePath = downloadToken.purchases.products.file_path;55 const { data: signedUrl } = await supabase.storage56 .from('downloads')57 .createSignedUrl(filePath, 60);5859 if (!signedUrl?.signedUrl) {60 return NextResponse.json({ error: 'File not found' }, { status: 500 });61 }6263 // Redirect to signed URL64 return NextResponse.redirect(signedUrl.signedUrl);65}6667// 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';7273export default async function DownloadPage({74 params,75}: {76 params: Promise<{ sessionId: string }>;77}) {78 const { sessionId } = await params;7980 const supabase = createClient(81 process.env.NEXT_PUBLIC_SUPABASE_URL!,82 process.env.SUPABASE_SERVICE_ROLE_KEY!83 );8485 // Find purchase by Stripe session ID86 const { data: purchase } = await supabase87 .from('purchases')88 .select('*, products(title), download_tokens(token, expires_at, used)')89 .eq('stripe_session_id', sessionId)90 .single();9192 if (!purchase) {93 return <div className="p-12 text-center">Purchase not found. Please check your email.</div>;94 }9596 const token = purchase.download_tokens?.[0];9798 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 Now113 </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
1import { NextRequest, NextResponse } from 'next/server';2import { createClient } from '@supabase/supabase-js';34export 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!);1011 const { data: downloadToken } = await supabase12 .from('download_tokens')13 .select('*, purchases(product_id, products(title, file_path))')14 .eq('token', token)15 .single();1617 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 }2627 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);2930 const filePath = downloadToken.purchases.products.file_path;31 const { data: signedUrl } = await supabase.storage.from('downloads').createSignedUrl(filePath, 60);3233 if (!signedUrl?.signedUrl) {34 return NextResponse.json({ error: 'File not found' }, { status: 500 });35 }3637 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation