Skip to main content
RapidDev - Software Development Agency

How to Build Donation system with V0

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.

What you'll build

  • Campaign listing page with goal progress bars using shadcn/ui Progress and Card
  • Donation form with preset amounts, custom input, and monthly recurring toggle via RadioGroup and Switch
  • Stripe Checkout integration for both one-time and subscription donation modes
  • Webhook handler that atomically increments campaign raised amounts via Supabase RPC
  • Thank-you confirmation page with social share buttons
  • Campaign owner dashboard with donation analytics and donor management
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate11 min read1-2 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
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.

What you're building

Fundraising platforms help nonprofits, creators, and community projects collect donations from supporters. Whether you are building a charity campaign, a crowdfunding page, or a recurring patron system, you need secure payment processing with real-time progress tracking.

V0 makes this faster by generating the donation form UI, Stripe integration code, and campaign pages from prompts. Add Stripe via the Vercel Marketplace for auto-provisioned API keys, and connect Supabase for campaign and donor data. The entire payment flow is built in the browser.

The architecture uses Next.js App Router with Server Components for campaign pages, API routes for Stripe Checkout session creation and webhook handling, Supabase for storing campaigns and donation records, and an RPC function for atomic raised amount updates to prevent race conditions.

Final result

A complete donation platform with campaign pages, one-time and recurring payment support via Stripe, real-time progress tracking, and a campaign owner analytics dashboard.

Tech stack

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
StripePayments

Prerequisites

  • 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

Build steps

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.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Build a donation platform. Create a Supabase schema with:
3// 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)
4// 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)
5// 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)
6// 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
7// 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.

app/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
3import { Progress } from '@/components/ui/progress'
4import { Button } from '@/components/ui/button'
5import Link from 'next/link'
6
7export default async function CampaignsPage() {
8 const supabase = await createClient()
9
10 const { data: campaigns } = await supabase
11 .from('campaigns')
12 .select('*')
13 .eq('status', 'active')
14 .order('created_at', { ascending: false })
15
16 return (
17 <div className="container mx-auto py-8">
18 <h1 className="text-4xl font-bold mb-8">Support a cause</h1>
19 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
20 {campaigns?.map((campaign) => {
21 const percent = Math.min(
22 (campaign.raised_cents / campaign.goal_cents) * 100,
23 100
24 )
25 return (
26 <Link key={campaign.id} href={`/campaigns/${campaign.id}`}>
27 <Card className="hover:shadow-md transition-shadow">
28 {campaign.image_url && (
29 <img
30 src={campaign.image_url}
31 alt={campaign.title}
32 className="w-full h-48 object-cover rounded-t-lg"
33 />
34 )}
35 <CardHeader>
36 <CardTitle>{campaign.title}</CardTitle>
37 </CardHeader>
38 <CardContent>
39 <Progress value={percent} className="mb-2" />
40 <p className="text-sm text-muted-foreground">
41 ${(campaign.raised_cents / 100).toLocaleString()} raised of $
42 {(campaign.goal_cents / 100).toLocaleString()}
43 </p>
44 </CardContent>
45 </Card>
46 </Link>
47 )
48 })}
49 </div>
50 </div>
51 )
52}

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.

app/api/donate/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
5
6export async function POST(req: NextRequest) {
7 const { campaignId, amountCents, isRecurring, donorName, donorEmail } =
8 await req.json()
9
10 if (!campaignId || !amountCents || amountCents < 100) {
11 return NextResponse.json(
12 { error: 'Invalid donation amount (minimum $1)' },
13 { status: 400 }
14 )
15 }
16
17 const sessionParams: Stripe.Checkout.SessionCreateParams = {
18 payment_method_types: ['card'],
19 customer_email: donorEmail,
20 metadata: { campaignId, donorName, isRecurring: String(isRecurring) },
21 success_url: `${req.nextUrl.origin}/donate/thank-you?session_id={CHECKOUT_SESSION_ID}`,
22 cancel_url: `${req.nextUrl.origin}/campaigns/${campaignId}`,
23 }
24
25 if (isRecurring) {
26 sessionParams.mode = 'subscription'
27 sessionParams.line_items = [
28 {
29 price_data: {
30 currency: 'usd',
31 unit_amount: amountCents,
32 recurring: { interval: 'month' },
33 product_data: { name: `Monthly donation` },
34 },
35 quantity: 1,
36 },
37 ]
38 } else {
39 sessionParams.mode = 'payment'
40 sessionParams.line_items = [
41 {
42 price_data: {
43 currency: 'usd',
44 unit_amount: amountCents,
45 product_data: { name: 'One-time donation' },
46 },
47 quantity: 1,
48 },
49 ]
50 }
51
52 const session = await stripe.checkout.sessions.create(sessionParams)
53
54 return NextResponse.json({ url: session.url })
55}

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.

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const body = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 try {
17 event = stripe.webhooks.constructEvent(
18 body,
19 sig,
20 process.env.STRIPE_WEBHOOK_SECRET!
21 )
22 } catch {
23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
24 }
25
26 if (event.type === 'checkout.session.completed') {
27 const session = event.data.object as Stripe.Checkout.Session
28 const { campaignId, donorName, isRecurring } = session.metadata ?? {}
29 const amountCents = session.amount_total ?? 0
30
31 await supabase.from('donations').insert({
32 campaign_id: campaignId,
33 donor_name: donorName,
34 donor_email: session.customer_email,
35 amount_cents: amountCents,
36 is_recurring: isRecurring === 'true',
37 stripe_payment_intent_id: session.payment_intent as string,
38 stripe_subscription_id: session.subscription as string,
39 })
40
41 await supabase.rpc('increment_raised', {
42 campaign_id: campaignId,
43 amount: amountCents,
44 })
45 }
46
47 if (event.type === 'invoice.paid') {
48 const invoice = event.data.object as Stripe.Invoice
49 const subscription = await stripe.subscriptions.retrieve(
50 invoice.subscription as string
51 )
52 const campaignId = subscription.metadata.campaignId
53 const amountCents = invoice.amount_paid
54
55 if (campaignId) {
56 await supabase.from('donations').insert({
57 campaign_id: campaignId,
58 donor_email: invoice.customer_email,
59 amount_cents: amountCents,
60 is_recurring: true,
61 stripe_subscription_id: invoice.subscription as string,
62 })
63
64 await supabase.rpc('increment_raised', {
65 campaign_id: campaignId,
66 amount: amountCents,
67 })
68 }
69 }
70
71 return NextResponse.json({ received: true })
72}

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.

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

app/api/webhooks/stripe/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import Stripe from 'stripe'
3import { createClient } from '@supabase/supabase-js'
4
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
6const supabase = createClient(
7 process.env.SUPABASE_URL!,
8 process.env.SUPABASE_SERVICE_ROLE_KEY!
9)
10
11export async function POST(req: NextRequest) {
12 const body = await req.text()
13 const sig = req.headers.get('stripe-signature')!
14
15 let event: Stripe.Event
16 try {
17 event = stripe.webhooks.constructEvent(
18 body,
19 sig,
20 process.env.STRIPE_WEBHOOK_SECRET!
21 )
22 } catch {
23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
24 }
25
26 if (event.type === 'checkout.session.completed') {
27 const session = event.data.object as Stripe.Checkout.Session
28 const { campaignId, donorName } = session.metadata ?? {}
29 const amountCents = session.amount_total ?? 0
30
31 await supabase.from('donations').insert({
32 campaign_id: campaignId,
33 donor_name: donorName,
34 donor_email: session.customer_email,
35 amount_cents: amountCents,
36 is_recurring: session.mode === 'subscription',
37 stripe_payment_intent_id: session.payment_intent as string,
38 })
39
40 await supabase.rpc('increment_raised', {
41 campaign_id: campaignId,
42 amount: amountCents,
43 })
44 }
45
46 return NextResponse.json({ received: true })
47}

Customization ideas

Add donor wall with avatars

Display a public donor wall on each campaign page showing recent donors with Avatar components, optionally showing 'Anonymous' for private donations.

Add campaign updates and milestones

Let campaign owners post text and image updates to keep donors informed about progress, displayed in a timeline below the campaign description.

Add team fundraising pages

Allow supporters to create personal fundraising pages that roll up into a parent campaign, tracking individual and team totals.

Add donation receipts via email

Integrate Resend to automatically send tax-deductible donation receipts with campaign details and donation amount after each successful payment.

Common pitfalls

Pitfall: Using request.json() instead of request.text() in the Stripe webhook handler

How to avoid: 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.

Pitfall: Incrementing raised_cents with a regular UPDATE instead of atomic RPC

How to avoid: Use a Supabase RPC function that does SET raised_cents = raised_cents + amount in a single atomic SQL statement, ensuring no donations are missed.

Pitfall: Adding NEXT_PUBLIC_ prefix to STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET

How to avoid: Only NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY gets the prefix. Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in the Vars tab without any prefix.

Pitfall: Not handling the invoice.paid event for recurring donations

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a donation system with Next.js and Stripe. I need to support both one-time and recurring monthly donations through Stripe Checkout. Show me how to create the Checkout session with conditional mode (payment vs subscription), handle the webhook events for both types, and atomically increment a campaign's raised amount in Supabase using an RPC function.

Build Prompt

Build the campaign owner dashboard for a donation system. Create app/dashboard/page.tsx as a Server Component that fetches all campaigns owned by the current user with their donation totals. Show campaign Card components with Progress bars, total raised, donor count, and recent donations Table. Include a BarChart (Recharts) showing donations over time. Add a Button to create new campaigns that opens a Dialog with title, description, goal amount, and image URL fields.

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.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.