# How to Build Donation system with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a donation platform with V0 using Next.js, Stripe for one-time and recurring payments, and Supabase for campaign tracking. You'll create campaign pages with progress bars, preset donation amounts, a recurring toggle, and a donor wall — all in about 1-2 hours without touching a terminal.

## Before you start

- A V0 account (Premium plan for multiple prompt iterations)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode — add via Vercel Marketplace in V0)
- Basic understanding of donations or fundraising goals

## Step-by-step guide

### 1. Set up the project with Supabase and Stripe

Create a new V0 project. Use the Connect panel to add Supabase for the database and Stripe via the Vercel Marketplace. This auto-provisions STRIPE_SECRET_KEY, NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, and Supabase keys into the Vars tab.

```
// Paste this prompt into V0's AI chat:
// Build a donation platform. Create a Supabase schema with:
// 1. campaigns: id (uuid PK), title (text), description (text), goal_cents (int), raised_cents (int default 0), image_url (text), owner_id (uuid FK to auth.users), status (text default 'active'), end_date (timestamptz), created_at (timestamptz)
// 2. donations: id (uuid PK), campaign_id (uuid FK to campaigns), donor_id (uuid FK to auth.users nullable), donor_name (text), donor_email (text), amount_cents (int), is_recurring (boolean default false), stripe_payment_intent_id (text), stripe_subscription_id (text), message (text), anonymous (boolean default false), created_at (timestamptz)
// 3. recurring_donations: id (uuid PK), donor_id (uuid FK to auth.users), campaign_id (uuid FK to campaigns), stripe_subscription_id (text unique), amount_cents (int), interval (text default 'month'), status (text default 'active'), created_at (timestamptz)
// Create a Supabase RPC function: increment_raised(campaign_id uuid, amount int) that does UPDATE campaigns SET raised_cents = raised_cents + amount WHERE id = campaign_id
// Add RLS policies: anyone can read active campaigns, authenticated users can donate.
```

> Pro tip: The Vercel Marketplace auto-provisions Stripe test keys. No manual key copying needed — just click Connect and both keys appear in the Vars tab.

**Expected result:** Supabase is connected with all tables created, the increment_raised RPC function exists, and Stripe keys are auto-provisioned in the Vars tab.

### 2. Build the campaign listing and detail pages

Create the homepage showing active campaigns with progress bars and a detail page for each campaign with the donation form. Campaign pages are Server Components for fast loading and SEO.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import Link from 'next/link'

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

  const { data: campaigns } = await supabase
    .from('campaigns')
    .select('*')
    .eq('status', 'active')
    .order('created_at', { ascending: false })

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-4xl font-bold mb-8">Support a cause</h1>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {campaigns?.map((campaign) => {
          const percent = Math.min(
            (campaign.raised_cents / campaign.goal_cents) * 100,
            100
          )
          return (
            <Link key={campaign.id} href={`/campaigns/${campaign.id}`}>
              <Card className="hover:shadow-md transition-shadow">
                {campaign.image_url && (
                  <img
                    src={campaign.image_url}
                    alt={campaign.title}
                    className="w-full h-48 object-cover rounded-t-lg"
                  />
                )}
                <CardHeader>
                  <CardTitle>{campaign.title}</CardTitle>
                </CardHeader>
                <CardContent>
                  <Progress value={percent} className="mb-2" />
                  <p className="text-sm text-muted-foreground">
                    ${(campaign.raised_cents / 100).toLocaleString()} raised of $
                    {(campaign.goal_cents / 100).toLocaleString()}
                  </p>
                </CardContent>
              </Card>
            </Link>
          )
        })}
      </div>
    </div>
  )
}
```

**Expected result:** The homepage shows active campaigns in a card grid with progress bars showing how much has been raised toward each goal.

### 3. Create the Stripe Checkout API route for donations

Build the API route that creates a Stripe Checkout session for both one-time and recurring donations. The route reads the donation amount and recurring flag from the request body, then redirects the donor to Stripe's hosted checkout page.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

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

export async function POST(req: NextRequest) {
  const { campaignId, amountCents, isRecurring, donorName, donorEmail } =
    await req.json()

  if (!campaignId || !amountCents || amountCents < 100) {
    return NextResponse.json(
      { error: 'Invalid donation amount (minimum $1)' },
      { status: 400 }
    )
  }

  const sessionParams: Stripe.Checkout.SessionCreateParams = {
    payment_method_types: ['card'],
    customer_email: donorEmail,
    metadata: { campaignId, donorName, isRecurring: String(isRecurring) },
    success_url: `${req.nextUrl.origin}/donate/thank-you?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${req.nextUrl.origin}/campaigns/${campaignId}`,
  }

  if (isRecurring) {
    sessionParams.mode = 'subscription'
    sessionParams.line_items = [
      {
        price_data: {
          currency: 'usd',
          unit_amount: amountCents,
          recurring: { interval: 'month' },
          product_data: { name: `Monthly donation` },
        },
        quantity: 1,
      },
    ]
  } else {
    sessionParams.mode = 'payment'
    sessionParams.line_items = [
      {
        price_data: {
          currency: 'usd',
          unit_amount: amountCents,
          product_data: { name: 'One-time donation' },
        },
        quantity: 1,
      },
    ]
  }

  const session = await stripe.checkout.sessions.create(sessionParams)

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

> Pro tip: Always store amounts in cents (integer) to avoid floating-point rounding errors. Convert to dollars only for display using (cents / 100).toLocaleString().

**Expected result:** POSTing to /api/donate with a campaign ID, amount, and recurring flag returns a Stripe Checkout URL. The donor is redirected to complete payment.

### 4. Handle Stripe webhooks to record donations

Build the webhook handler that listens for Stripe checkout.session.completed and invoice.paid events. On payment confirmation, it inserts the donation record and atomically increments the campaign's raised_cents using the Supabase RPC function.

```
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!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

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

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const { campaignId, donorName, isRecurring } = session.metadata ?? {}
    const amountCents = session.amount_total ?? 0

    await supabase.from('donations').insert({
      campaign_id: campaignId,
      donor_name: donorName,
      donor_email: session.customer_email,
      amount_cents: amountCents,
      is_recurring: isRecurring === 'true',
      stripe_payment_intent_id: session.payment_intent as string,
      stripe_subscription_id: session.subscription as string,
    })

    await supabase.rpc('increment_raised', {
      campaign_id: campaignId,
      amount: amountCents,
    })
  }

  if (event.type === 'invoice.paid') {
    const invoice = event.data.object as Stripe.Invoice
    const subscription = await stripe.subscriptions.retrieve(
      invoice.subscription as string
    )
    const campaignId = subscription.metadata.campaignId
    const amountCents = invoice.amount_paid

    if (campaignId) {
      await supabase.from('donations').insert({
        campaign_id: campaignId,
        donor_email: invoice.customer_email,
        amount_cents: amountCents,
        is_recurring: true,
        stripe_subscription_id: invoice.subscription as string,
      })

      await supabase.rpc('increment_raised', {
        campaign_id: campaignId,
        amount: amountCents,
      })
    }
  }

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

**Expected result:** When a donor completes payment, the webhook inserts a donation record and atomically increments the campaign's raised amount. Recurring payments are tracked on each invoice.

### 5. Build the donation form and thank-you page

Create the interactive donation form on the campaign detail page with preset amounts, a custom amount input, and a monthly recurring toggle. Add a thank-you page that confirms the donation and offers sharing options.

```
// Paste this prompt into V0's AI chat:
// Build two components for the donation system:
// 1. A 'use client' DonationForm component for the campaign detail page with:
//    - RadioGroup with preset amounts: $10, $25, $50, $100
//    - A "Custom" option that reveals an Input for entering a custom dollar amount
//    - Switch toggle labeled "Make this monthly" for recurring donations
//    - Textarea for an optional message to the campaign
//    - Button labeled "Donate ${amount}" that POSTs to /api/donate and redirects to the returned Stripe Checkout URL
//    - Show campaign Progress bar and current raised amount above the form
//    - Use Card to wrap the entire form section
// 2. A thank-you page at app/donate/thank-you/page.tsx that:
//    - Reads session_id from searchParams and fetches session details from Stripe
//    - Shows a success Card with the donation amount, campaign name, and a checkmark icon
//    - Includes share Button components for Twitter, Facebook, and copy link
//    - Links back to the campaign and to browse more campaigns
```

> Pro tip: Use Design Mode (Option+D) to adjust the donation preset button sizes, progress bar colors, and form layout for mobile responsiveness — all free, no credits spent.

**Expected result:** The campaign page shows a donation form with preset amounts and a recurring toggle. After payment, donors see a thank-you page with sharing options.

## Complete code example

File: `app/api/webhooks/stripe/route.ts`

```typescript
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!)
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

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

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

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const { campaignId, donorName } = session.metadata ?? {}
    const amountCents = session.amount_total ?? 0

    await supabase.from('donations').insert({
      campaign_id: campaignId,
      donor_name: donorName,
      donor_email: session.customer_email,
      amount_cents: amountCents,
      is_recurring: session.mode === 'subscription',
      stripe_payment_intent_id: session.payment_intent as string,
    })

    await supabase.rpc('increment_raised', {
      campaign_id: campaignId,
      amount: amountCents,
    })
  }

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

## Common mistakes

- **Using request.json() instead of request.text() in the Stripe webhook handler** — Stripe signature verification requires the raw request body as text. Parsing as JSON first alters the body and causes signature mismatch errors. Fix: Always use await req.text() to get the raw body, then pass it to stripe.webhooks.constructEvent() along with the signature header and webhook secret.
- **Incrementing raised_cents with a regular UPDATE instead of atomic RPC** — When multiple donations arrive simultaneously, a read-then-write pattern causes race conditions where some increments are lost. Fix: Use a Supabase RPC function that does SET raised_cents = raised_cents + amount in a single atomic SQL statement, ensuring no donations are missed.
- **Adding NEXT_PUBLIC_ prefix to STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET** — The NEXT_PUBLIC_ prefix exposes variables to the browser. Stripe secret keys must never be visible on the client side. Fix: Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY gets the prefix. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in the Vars tab without any prefix.
- **Not handling the invoice.paid event for recurring donations** — checkout.session.completed only fires on the first subscription payment. Subsequent monthly charges trigger invoice.paid instead. Fix: Register both checkout.session.completed and invoice.paid in your Stripe webhook settings, and handle both events in the webhook route to track every recurring payment.

## Best practices

- Use Stripe Checkout (hosted page) instead of building a custom payment form — it handles PCI compliance, 3D Secure, and mobile optimization automatically.
- Store all monetary values in cents as integers to avoid floating-point rounding errors. Convert to dollars only for display.
- Use Supabase RPC functions for atomic counter updates (raised_cents) to prevent race conditions under concurrent donations.
- Add Stripe via the Vercel Marketplace in V0's Connect panel for auto-provisioned test keys — no manual key copying needed.
- Use Design Mode (Option+D) to adjust the donation form layout and progress bar styling without spending V0 credits.
- Register both checkout.session.completed and invoice.paid webhook events to capture both first-time and recurring subscription payments.
- Set STRIPE_WEBHOOK_SECRET in the Vars tab without NEXT_PUBLIC_ prefix — this key must stay server-side.
- Return 200 quickly from the webhook handler and process async where possible to avoid Stripe timeout retries.

## Frequently asked questions

### Can I accept both one-time and recurring donations with Stripe?

Yes. Create the Stripe Checkout session with mode 'payment' for one-time donations or mode 'subscription' for recurring. The donation form's recurring toggle determines which mode to use when calling the API route.

### How do I test donations without real money?

Stripe test mode is enabled by default when you add Stripe via the Vercel Marketplace. Use test card number 4242 4242 4242 4242 with any future expiry date. Test webhooks using the Stripe CLI's listen command or the Stripe Dashboard webhook tester.

### What happens if two people donate at the exact same time?

The Supabase RPC function increment_raised uses an atomic SQL UPDATE (SET raised_cents = raised_cents + amount) which is safe for concurrent access. PostgreSQL handles the row-level locking automatically.

### How do I deploy and register the webhook URL?

Publish your project via V0's Share menu to get a Vercel URL. Then go to Stripe Dashboard, Developers, Webhooks, and add an endpoint pointing to https://yourdomain.com/api/webhooks/stripe. Select checkout.session.completed and invoice.paid events.

### Can donors remain anonymous?

Yes. The donation form includes an anonymous toggle. When enabled, the donor's name is hidden on the public campaign page, but the campaign owner can still see donor details in their dashboard for tax and reporting purposes.

### Can RapidDev help build a custom donation system?

Yes. RapidDev has built 600+ apps including nonprofit fundraising platforms with advanced features like team fundraising, recurring donor management, and tax receipt generation. Book a free consultation to scope your project.

### Do I need a paid V0 plan for this project?

The Premium plan ($20/month) is recommended since the Stripe integration requires multiple prompt iterations. The free tier works for the basic UI but may require manual code editing for the webhook handler.

---

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