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
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
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.
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_id7// 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.
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.
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'67export default async function CampaignsPage() {8 const supabase = await createClient()910 const { data: campaigns } = await supabase11 .from('campaigns')12 .select('*')13 .eq('status', 'active')14 .order('created_at', { ascending: false })1516 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 10024 )25 return (26 <Link key={campaign.id} href={`/campaigns/${campaign.id}`}>27 <Card className="hover:shadow-md transition-shadow">28 {campaign.image_url && (29 <img30 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'34const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)56export async function POST(req: NextRequest) {7 const { campaignId, amountCents, isRecurring, donorName, donorEmail } =8 await req.json()910 if (!campaignId || !amountCents || amountCents < 100) {11 return NextResponse.json(12 { error: 'Invalid donation amount (minimum $1)' },13 { status: 400 }14 )15 }1617 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 }2425 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 }5152 const session = await stripe.checkout.sessions.create(sessionParams)5354 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const body = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 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 }2526 if (event.type === 'checkout.session.completed') {27 const session = event.data.object as Stripe.Checkout.Session28 const { campaignId, donorName, isRecurring } = session.metadata ?? {}29 const amountCents = session.amount_total ?? 03031 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 })4041 await supabase.rpc('increment_raised', {42 campaign_id: campaignId,43 amount: amountCents,44 })45 }4647 if (event.type === 'invoice.paid') {48 const invoice = event.data.object as Stripe.Invoice49 const subscription = await stripe.subscriptions.retrieve(50 invoice.subscription as string51 )52 const campaignId = subscription.metadata.campaignId53 const amountCents = invoice.amount_paid5455 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 })6364 await supabase.rpc('increment_raised', {65 campaign_id: campaignId,66 amount: amountCents,67 })68 }69 }7071 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.
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.
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, $1005// - A "Custom" option that reveals an Input for entering a custom dollar amount6// - Switch toggle labeled "Make this monthly" for recurring donations7// - Textarea for an optional message to the campaign8// - Button labeled "Donate ${amount}" that POSTs to /api/donate and redirects to the returned Stripe Checkout URL9// - Show campaign Progress bar and current raised amount above the form10// - Use Card to wrap the entire form section11// 2. A thank-you page at app/donate/thank-you/page.tsx that:12// - Reads session_id from searchParams and fetches session details from Stripe13// - Shows a success Card with the donation amount, campaign name, and a checkmark icon14// - Includes share Button components for Twitter, Facebook, and copy link15// - Links back to the campaign and to browse more campaignsPro 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
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const body = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 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 }2526 if (event.type === 'checkout.session.completed') {27 const session = event.data.object as Stripe.Checkout.Session28 const { campaignId, donorName } = session.metadata ?? {}29 const amountCents = session.amount_total ?? 03031 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 })3940 await supabase.rpc('increment_raised', {41 campaign_id: campaignId,42 amount: amountCents,43 })44 }4546 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.
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 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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation