# How to Build a Digital Downloads Store with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or Free tier, Supabase free tier, Stripe account
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 1. Set Up the Supabase Schema and Storage Bucket

*A private Storage bucket ensures files cannot be accessed directly by URL. The download_tokens table with unique token and expiry fields enables secure, time-limited, single-use file delivery.*

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.

```
-- Products
create table products (
  id uuid primary key default gen_random_uuid(),
  creator_id uuid references auth.users not null,
  title text not null,
  description text,
  price_cents int not null,
  file_path text not null,  -- path in Supabase Storage
  preview_image_url text,
  format text default 'pdf',
  download_count int default 0,
  is_active boolean default true,
  created_at timestamptz default now()
);

-- Purchases
create table purchases (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users,
  product_id uuid references products not null,
  email text not null,
  stripe_session_id text unique not null,
  downloaded_at timestamptz,
  created_at timestamptz default now()
);

-- Download tokens (single-use, time-limited)
create table download_tokens (
  id uuid primary key default gen_random_uuid(),
  purchase_id uuid references purchases not null,
  token text unique not null,
  expires_at timestamptz not null,
  used boolean default false
);

-- RLS
alter table products enable row level security;
alter table purchases enable row level security;
alter table download_tokens enable row level security;

-- Anyone can read active products
create policy "Public read products" on products for select using (is_active = true);
-- Creators manage their own products
create policy "Creators manage products" on products for all using (auth.uid() = creator_id);
-- Users read own purchases
create policy "Users read purchases" on purchases for select using (auth.uid() = user_id);
-- Download tokens readable by purchase owner
create policy "Token access" on download_tokens for select
  using (purchase_id in (select id from purchases where user_id = auth.uid()));

-- Create private Storage bucket (run in Supabase Dashboard > Storage):
-- Bucket name: downloads
-- Public: OFF
-- File size limit: 100MB
-- 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

*Server Components render the storefront with zero client-side JavaScript for fast load times and SEO indexability. generateMetadata provides unique meta tags for each product page.*

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.

```
// app/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import Image from 'next/image';

export default async function StorePage() {
  const supabase = await createClient();

  const { data: products } = await supabase
    .from('products')
    .select('*')
    .eq('is_active', true)
    .order('created_at', { ascending: false });

  return (
    <div className="max-w-6xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-2">Digital Downloads</h1>
      <p className="text-muted-foreground mb-8">Premium templates, guides, and design assets</p>

      <div className="grid md:grid-cols-3 gap-6">
        {products?.map((product) => (
          <Link key={product.id} href={`/products/${product.id}`}>
            <Card className="overflow-hidden hover:border-primary transition-colors">
              {product.preview_image_url && (
                <div className="aspect-[4/3] relative bg-muted">
                  <Image
                    src={product.preview_image_url}
                    alt={product.title}
                    fill
                    className="object-cover"
                  />
                </div>
              )}
              <CardContent className="pt-4">
                <h2 className="font-semibold">{product.title}</h2>
                <p className="text-sm text-muted-foreground line-clamp-2 mt-1">
                  {product.description}
                </p>
              </CardContent>
              <CardFooter className="flex items-center justify-between">
                <span className="text-lg font-bold">
                  ${(product.price_cents / 100).toFixed(2)}
                </span>
                <Badge variant="secondary">{product.format.toUpperCase()}</Badge>
              </CardFooter>
            </Card>
          </Link>
        ))}
      </div>
    </div>
  );
}

// app/products/[id]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { BuyButton } from './buy-button';
import Image from 'next/image';
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ id: string }>;
}): Promise<Metadata> {
  const { id } = await params;
  const supabase = await createClient();
  const { data: product } = await supabase.from('products').select('title, description').eq('id', id).single();
  return {
    title: product?.title || 'Product',
    description: product?.description || '',
  };
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const supabase = await createClient();

  const { data: product } = await supabase
    .from('products')
    .select('*')
    .eq('id', id)
    .single();

  if (!product) return <div className="p-12 text-center">Product not found</div>;

  return (
    <div className="max-w-3xl mx-auto p-6">
      <div className="grid md:grid-cols-2 gap-8">
        <div className="aspect-square relative rounded-lg overflow-hidden bg-muted">
          {product.preview_image_url ? (
            <Image src={product.preview_image_url} alt={product.title} fill className="object-cover" />
          ) : (
            <Skeleton className="h-full w-full" />
          )}
        </div>
        <div className="space-y-4">
          <Badge variant="secondary">{product.format.toUpperCase()}</Badge>
          <h1 className="text-2xl font-bold">{product.title}</h1>
          <p className="text-muted-foreground">{product.description}</p>
          <p className="text-3xl font-bold">${(product.price_cents / 100).toFixed(2)}</p>
          <BuyButton productId={product.id} />
          <p className="text-sm text-muted-foreground">
            {product.download_count} downloads
          </p>
        </div>
      </div>
    </div>
  );
}
```

**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

*Stripe Checkout handles the entire payment UI on a hosted page, eliminating PCI compliance complexity. Passing the product_id in metadata lets the webhook identify which product was purchased.*

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.

```
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const { productId } = await request.json();
  const supabase = await createClient();

  const { data: product } = await supabase
    .from('products')
    .select('*')
    .eq('id', productId)
    .single();

  if (!product) {
    return NextResponse.json({ error: 'Product not found' }, { status: 404 });
  }

  // Get user if authenticated (optional for guest checkout)
  const { data: { user } } = await supabase.auth.getUser();

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: {
          name: product.title,
          description: product.description || undefined,
          images: product.preview_image_url ? [product.preview_image_url] : [],
        },
        unit_amount: product.price_cents,
      },
      quantity: 1,
    }],
    metadata: {
      product_id: productId,
      user_id: user?.id || '',
    },
    customer_email: user?.email || undefined,
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/download/{CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/products/${productId}`,
  });

  return NextResponse.json({ url: session.url });
}

// app/products/[id]/buy-button.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { ShoppingCart } from 'lucide-react';

export function BuyButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);

  async function handleBuy() {
    setLoading(true);
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ productId }),
    });
    const { url } = await res.json();
    if (url) window.location.href = url;
    setLoading(false);
  }

  return (
    <Button onClick={handleBuy} disabled={loading} size="lg" className="w-full">
      <ShoppingCart className="h-4 w-4 mr-2" />
      {loading ? 'Redirecting...' : 'Buy Now'}
    </Button>
  );
}
```

**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

*Generating download tokens only after confirmed payment prevents unauthorized access. Single-use tokens with 24-hour expiry prevent URL sharing while giving customers a reasonable download window.*

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.

```
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  const body = await request.text();
  const sig = request.headers.get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    const { product_id, user_id } = session.metadata!;

    // Use service role for webhook handler
    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.SUPABASE_SERVICE_ROLE_KEY!
    );

    // Create purchase record
    const { data: purchase } = await supabase
      .from('purchases')
      .insert({
        product_id,
        user_id: user_id || null,
        email: session.customer_details?.email || '',
        stripe_session_id: session.id,
      })
      .select()
      .single();

    if (purchase) {
      // Generate single-use download token
      const token = crypto.randomUUID().replace(/-/g, '');
      const expiresAt = new Date();
      expiresAt.setHours(expiresAt.getHours() + 24);

      await supabase.from('download_tokens').insert({
        purchase_id: purchase.id,
        token,
        expires_at: expiresAt.toISOString(),
      });

      // Increment download count
      await supabase.rpc('increment_download_count', {
        p_product_id: product_id,
      });
    }
  }

  return NextResponse.json({ received: true });
}

// Supabase function (run in SQL editor):
// create or replace function increment_download_count(p_product_id uuid)
// returns void as $$
// begin
//   update products set download_count = download_count + 1
//   where id = p_product_id;
// end;
// $$ 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

*Validating tokens before serving files prevents URL sharing and unauthorized access. Generating a 60-second Supabase Storage signed URL limits the window for link sharing even further.*

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.

```
// app/download/[token]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ token: string }> }
) {
  const { token } = await params;

  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  // Find and validate token
  const { data: downloadToken } = await supabase
    .from('download_tokens')
    .select('*, purchases(product_id, products(title, file_path))')
    .eq('token', token)
    .single();

  if (!downloadToken) {
    return NextResponse.json({ error: 'Invalid download link' }, { status: 404 });
  }

  if (downloadToken.used) {
    return NextResponse.json(
      { error: 'This download link has already been used' },
      { status: 410 }
    );
  }

  if (new Date(downloadToken.expires_at) < new Date()) {
    return NextResponse.json(
      { error: 'This download link has expired' },
      { status: 410 }
    );
  }

  // Mark token as used
  await supabase
    .from('download_tokens')
    .update({ used: true })
    .eq('id', downloadToken.id);

  // Update purchase downloaded_at
  await supabase
    .from('purchases')
    .update({ downloaded_at: new Date().toISOString() })
    .eq('id', downloadToken.purchase_id);

  // Generate 60-second signed URL from private Storage bucket
  const filePath = downloadToken.purchases.products.file_path;
  const { data: signedUrl } = await supabase.storage
    .from('downloads')
    .createSignedUrl(filePath, 60);

  if (!signedUrl?.signedUrl) {
    return NextResponse.json({ error: 'File not found' }, { status: 500 });
  }

  // Redirect to signed URL
  return NextResponse.redirect(signedUrl.signedUrl);
}

// app/download/[sessionId]/page.tsx (success page after checkout)
import { createClient } from '@supabase/supabase-js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Download, CheckCircle } from 'lucide-react';

export default async function DownloadPage({
  params,
}: {
  params: Promise<{ sessionId: string }>;
}) {
  const { sessionId } = await params;

  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  // Find purchase by Stripe session ID
  const { data: purchase } = await supabase
    .from('purchases')
    .select('*, products(title), download_tokens(token, expires_at, used)')
    .eq('stripe_session_id', sessionId)
    .single();

  if (!purchase) {
    return <div className="p-12 text-center">Purchase not found. Please check your email.</div>;
  }

  const token = purchase.download_tokens?.[0];

  return (
    <div className="max-w-md mx-auto p-6">
      <Card>
        <CardHeader className="text-center">
          <CheckCircle className="h-12 w-12 text-green-500 mx-auto" />
          <CardTitle className="mt-2">Payment Successful</CardTitle>
        </CardHeader>
        <CardContent className="text-center space-y-4">
          <p className="text-muted-foreground">
            Thank you for purchasing {purchase.products.title}.
          </p>
          {token && !token.used ? (
            <Button asChild size="lg" className="w-full">
              <a href={`/download/${token.token}`}>
                <Download className="h-4 w-4 mr-2" /> Download Now
              </a>
            </Button>
          ) : (
            <p className="text-sm text-muted-foreground">Your download link has been used.</p>
          )}
          <p className="text-xs text-muted-foreground">
            This link expires in 24 hours and can only be used once.
          </p>
        </CardContent>
      </Card>
    </div>
  );
}
```

**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 example

File: `app/download/[token]/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';

export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ token: string }> }
) {
  const { token } = await params;
  const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

  const { data: downloadToken } = await supabase
    .from('download_tokens')
    .select('*, purchases(product_id, products(title, file_path))')
    .eq('token', token)
    .single();

  if (!downloadToken) {
    return NextResponse.json({ error: 'Invalid download link' }, { status: 404 });
  }
  if (downloadToken.used) {
    return NextResponse.json({ error: 'This download link has already been used' }, { status: 410 });
  }
  if (new Date(downloadToken.expires_at) < new Date()) {
    return NextResponse.json({ error: 'This download link has expired' }, { status: 410 });
  }

  await supabase.from('download_tokens').update({ used: true }).eq('id', downloadToken.id);
  await supabase.from('purchases').update({ downloaded_at: new Date().toISOString() }).eq('id', downloadToken.purchase_id);

  const filePath = downloadToken.purchases.products.file_path;
  const { data: signedUrl } = await supabase.storage.from('downloads').createSignedUrl(filePath, 60);

  if (!signedUrl?.signedUrl) {
    return NextResponse.json({ error: 'File not found' }, { status: 500 });
  }

  return NextResponse.redirect(signedUrl.signedUrl);
}
```

## Common mistakes

- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined

## 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/digital-downloads
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/digital-downloads
