Skip to main content
RapidDev - Software Development Agency

How to Build a Donation System with Lovable

Build a Stripe-powered donation system in Lovable supporting one-time and recurring donations, campaign progress with a shadcn/ui Progress bar, and automated PDF receipt generation via a Supabase Edge Function using pdf-lib and Resend. Atomic SQL increments keep campaign totals accurate under concurrent donations.

What you'll build

  • Donation form with preset amount Buttons and a custom amount Input, plus one-time or monthly toggle
  • Campaign page with shadcn/ui Progress bar showing amount raised vs goal, and donor count
  • Stripe PaymentIntent for one-time donations and Stripe Subscription for recurring donations
  • Webhook handler updating campaign totals using atomic SQL increment to prevent race conditions
  • PDF donation receipt generated by a Supabase Edge Function and emailed to the donor
  • Donor wall section showing recent donors with optional anonymity toggle
  • Admin dashboard with total raised, donor count, and monthly recurring revenue Card
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate15 min read2–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a Stripe-powered donation system in Lovable supporting one-time and recurring donations, campaign progress with a shadcn/ui Progress bar, and automated PDF receipt generation via a Supabase Edge Function using pdf-lib and Resend. Atomic SQL increments keep campaign totals accurate under concurrent donations.

What you're building

The donation system handles two Stripe flows: one-time donations use PaymentIntents (amount collected once) and recurring donations use Subscriptions (amount collected monthly until canceled). A single donation form with a toggle switches between these modes, and the Edge Function determines which Stripe API to call based on the selected type.

Campaign totals must be accurate even when multiple donations arrive at the same moment. Rather than reading the current total and incrementing in application code (which has a race condition), the webhook handler uses a Supabase RPC function with UPDATE campaigns SET amount_raised = amount_raised + $1 WHERE id = $2. PostgreSQL's row-level lock during UPDATE makes this atomic — no two concurrent increments can conflict.

PDF receipts use the IRS-friendly format for charitable donations: organization name, donor name, date, amount, and a statement that no goods or services were provided in exchange. An Edge Function generates the PDF using pdf-lib from esm.sh and attaches it to an email sent via Resend.

Final result

A fully functional donation platform with campaign progress tracking, one-time and recurring donations, atomic totals, and automatic PDF receipt delivery.

Tech stack

LovableFrontend app builder
StripePayment processing for one-time and recurring
SupabaseDatabase and Edge Functions
shadcn/uiUI components
RechartsDonation trend chart
React Hook Form + ZodDonation form validation

Prerequisites

  • Lovable Pro account for Edge Function generation
  • Stripe account with STRIPE_SECRET_KEY, VITE_STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets
  • Resend account with RESEND_API_KEY in Secrets (for receipt emails)
  • Supabase project with service role key in Secrets
  • Deployed Lovable app URL (Stripe payment flows do not work in preview mode)

Build steps

1

Set up the donation schema

Create the Supabase tables for campaigns, donations, and donors, plus the atomic increment RPC function that the webhook will use.

prompt.txt
1Create a donation system schema in Supabase:
2
3Tables:
4- campaigns: id (uuid pk), title (text), description (text), goal_cents (int), amount_raised_cents (int default 0), donor_count (int default 0), currency (text default 'usd'), start_date (date), end_date (date nullable), is_active (bool default true), image_url (text), created_at
5- donations: id (uuid pk), campaign_id (uuid references campaigns), donor_name (text), donor_email (text), amount_cents (int), currency (text default 'usd'), type (text: one_time|recurring), stripe_payment_intent_id (text), stripe_subscription_id (text), status (text: pending|succeeded|failed), is_anonymous (bool default false), message (text nullable), receipt_sent (bool default false), created_at
6- recurring_donors: id (uuid pk), donation_id (uuid references donations), stripe_subscription_id (text unique), stripe_customer_id (text), monthly_amount_cents (int), status (text: active|canceled), next_billing_date (timestamptz), created_at
7
8RLS:
9- campaigns: public SELECT, admin-only INSERT/UPDATE
10- donations: public INSERT (anyone can donate), SELECT own donations only (by email match), service role full access
11- recurring_donors: service role only
12
13Create a Supabase RPC function increment_campaign(p_campaign_id uuid, p_amount_cents int):
14UPDATE campaigns SET amount_raised_cents = amount_raised_cents + p_amount_cents, donor_count = donor_count + 1 WHERE id = p_campaign_id;
15Grant execute to service role.

Pro tip: Create an index on donations.campaign_id and donations.created_at so the donor wall and recent donations queries are fast even with thousands of donation rows.

Expected result: All three tables are created. The increment_campaign RPC function is ready. Campaigns can have public read access. TypeScript types are generated.

2

Build the donation form and campaign page

Ask Lovable to create the main campaign page with progress tracking and the donation form with preset amounts and a one-time/recurring toggle.

prompt.txt
1Build a campaign donation page at src/pages/Campaign.tsx.
2
3Top section - Campaign hero:
4- Campaign title, description, and image
5- Progress section: amount raised formatted as currency, goal amount, percentage (amount/goal * 100)
6- shadcn/ui Progress bar showing fill percentage (cap at 100%)
7- Two stats below the bar: '{donor_count} donors' and 'Goal: {goal_amount}'
8
9Donation form (right column on desktop, below hero on mobile):
10- One-time / Monthly toggle using shadcn/ui Tabs
11- Preset amount Buttons: $10, $25, $50, $100, $250, Custom
12- When Custom is selected, show an Input field for the amount
13- Donor name Input (required)
14- Donor email Input (required)
15- Optional message Textarea
16- Anonymous Checkbox: 'Donate anonymously (your name won't appear on the donor wall)'
17- Donate Button: 'Donate ${amount} {one-time|monthly}'
18- Small text below: 'Secure payment via Stripe. You'll receive a PDF receipt by email.'
19
20Donor wall section below:
21- 'Recent Donors' heading
22- List of last 10 non-anonymous donations: donor name, amount, time ago, optional message in a Card
23- Anonymous donors show as 'Anonymous donor'
24- Real-time updates using Supabase Realtime channel subscription on the donations table

Pro tip: Cache the campaign data (title, goal, raised amount) locally and update the Progress bar optimistically when the user submits their donation — before the webhook confirms. Roll back if the payment fails. This makes the form feel instantly responsive.

Expected result: The campaign page shows progress bar, preset amount buttons, and the donation form. Selecting One-time or Monthly switches the Tabs. The donor wall shows existing donations and updates in real time.

3

Create the donation processing Edge Function

Build the Edge Function that creates either a Stripe PaymentIntent or Subscription based on donation type, and saves a pending donation to Supabase.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/process-donation/index.ts.
2
3Accept POST body: { campaignId, donorName, donorEmail, amountCents, type ('one_time'|'recurring'), message?, isAnonymous? }
4
5For type='one_time':
61. Create a Stripe PaymentIntent via POST to /v1/payment_intents with amount, currency='usd', receipt_email=donorEmail, metadata={campaignId, donorName}
72. Insert a pending donation row into Supabase donations table
83. Return { clientSecret: pi.client_secret }
9
10For type='recurring':
111. Create or find a Stripe customer with donorEmail
122. Do NOT create the subscription yet return a SetupIntent to collect the payment method first
133. Create a Stripe SetupIntent via POST to /v1/setup_intents with customer, usage='off_session', metadata={campaignId, donorName, amountCents}
144. Insert a pending donation row
155. Return { setupClientSecret: si.client_secret, customerId: customer.id }
16
17After the frontend confirms the SetupIntent, it calls a second Edge Function create-recurring-donation that creates the actual Stripe Subscription using the confirmed payment method.
18
19Include CORS headers and OPTIONS handling.

Pro tip: For recurring donations, create a Stripe Price on the fly using the API (POST to /v1/prices with unit_amount, currency, and recurring.interval=month) rather than pre-creating prices for every possible donation amount. This keeps your Stripe Dashboard clean.

Expected result: The process-donation Edge Function returns a clientSecret for one-time donations and a setupClientSecret for recurring. The Stripe Elements form can be loaded with either secret type.

4

Build the webhook handler with atomic increment

Create the webhook Edge Function that verifies Stripe events, atomically updates campaign totals, and triggers PDF receipt generation.

supabase/functions/donation-webhook/index.ts
1// supabase/functions/donation-webhook/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4import Stripe from 'https://esm.sh/stripe@14?target=deno'
5
6const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
7 apiVersion: '2023-10-16',
8 httpClient: Stripe.createFetchHttpClient(),
9})
10const cryptoProvider = Stripe.createSubtleCryptoProvider()
11
12serve(async (req: Request) => {
13 const body = await req.text()
14 const sig = req.headers.get('stripe-signature') ?? ''
15 let event: Stripe.Event
16 try {
17 event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '', undefined, cryptoProvider)
18 } catch {
19 return new Response('Signature failed', { status: 400 })
20 }
21
22 const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
23
24 if (event.type === 'payment_intent.succeeded') {
25 const pi = event.data.object as Stripe.PaymentIntent
26 const campaignId = pi.metadata?.campaignId
27
28 const { data: donation } = await supabase
29 .from('donations')
30 .update({ status: 'succeeded' })
31 .eq('stripe_payment_intent_id', pi.id)
32 .select()
33 .single()
34
35 if (donation && campaignId) {
36 await supabase.rpc('increment_campaign', {
37 p_campaign_id: campaignId,
38 p_amount_cents: donation.amount_cents,
39 })
40
41 if (!donation.receipt_sent) {
42 await supabase.functions.invoke('send-donation-receipt', {
43 body: { donationId: donation.id },
44 })
45 await supabase.from('donations').update({ receipt_sent: true }).eq('id', donation.id)
46 }
47 }
48 }
49
50 if (event.type === 'invoice.paid') {
51 const inv = event.data.object as Stripe.Invoice
52 const subId = typeof inv.subscription === 'string' ? inv.subscription : inv.subscription?.id
53 if (subId) {
54 const { data: rd } = await supabase.from('recurring_donors').select('donation_id').eq('stripe_subscription_id', subId).single()
55 if (rd) {
56 const { data: originalDonation } = await supabase.from('donations').select('campaign_id, amount_cents, donor_email, donor_name').eq('id', rd.donation_id).single()
57 if (originalDonation) {
58 await supabase.rpc('increment_campaign', { p_campaign_id: originalDonation.campaign_id, p_amount_cents: originalDonation.amount_cents })
59 }
60 }
61 }
62 }
63
64 return new Response(JSON.stringify({ received: true }), { status: 200 })
65})

Pro tip: The supabase.functions.invoke call within an Edge Function triggers the receipt generation asynchronously. This keeps the webhook handler fast — it returns 200 to Stripe immediately while receipt generation happens in the background.

Expected result: A completed test payment triggers the webhook. The campaign's amount_raised_cents increments atomically. The receipt Edge Function is invoked asynchronously. The donor wall updates via Realtime.

5

Build the PDF receipt Edge Function

Create an Edge Function that generates a PDF donation receipt using pdf-lib and sends it via Resend email.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/send-donation-receipt/index.ts.
2
3Accept POST body: { donationId: string }
4
5Logic:
61. Fetch the donation joined with campaigns from Supabase using donationId
72. Use pdf-lib from esm.sh to create a simple PDF receipt:
8 - Title: 'Donation Receipt'
9 - Organization name from an env var ORGANIZATION_NAME
10 - Receipt number: donation.id (first 8 chars)
11 - Donor name (or 'Anonymous' if is_anonymous)
12 - Date: formatted from donation.created_at
13 - Amount: formatted as currency
14 - Campaign name
15 - Statement: 'No goods or services were provided in exchange for this donation.'
16 - Footer with organization contact info from env vars
173. Serialize the PDF to bytes
184. Send via Resend API: POST to https://api.resend.com/emails with:
19 - from: noreply@yourdomain.com (from RESEND_FROM_EMAIL env var)
20 - to: donation.donor_email
21 - subject: 'Your donation receipt from {ORGANIZATION_NAME}'
22 - html: simple thank-you message
23 - attachments: [{ filename: 'receipt.pdf', content: base64-encoded PDF bytes }]
245. Return { success: true }

Expected result: After a test donation, the receipt Edge Function generates a PDF and sends an email to the donor's address. The PDF contains all required receipt fields.

Complete code

supabase/functions/donation-webhook/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3import Stripe from 'https://esm.sh/stripe@14?target=deno'
4
5const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '', {
6 apiVersion: '2023-10-16',
7 httpClient: Stripe.createFetchHttpClient(),
8})
9const cryptoProvider = Stripe.createSubtleCryptoProvider()
10
11serve(async (req: Request) => {
12 const body = await req.text()
13 const sig = req.headers.get('stripe-signature') ?? ''
14 let event: Stripe.Event
15
16 try {
17 event = await stripe.webhooks.constructEventAsync(
18 body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '',
19 undefined, cryptoProvider
20 )
21 } catch {
22 return new Response('Signature failed', { status: 400 })
23 }
24
25 const supabase = createClient(
26 Deno.env.get('SUPABASE_URL') ?? '',
27 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
28 )
29
30 if (event.type === 'payment_intent.succeeded') {
31 const pi = event.data.object as Stripe.PaymentIntent
32 const campaignId = pi.metadata?.campaignId
33 if (!campaignId) return new Response(JSON.stringify({ received: true }), { status: 200 })
34
35 const { data: donation } = await supabase
36 .from('donations')
37 .update({ status: 'succeeded' })
38 .eq('stripe_payment_intent_id', pi.id)
39 .select('id, amount_cents, donor_email, receipt_sent')
40 .single()
41
42 if (donation) {
43 await supabase.rpc('increment_campaign', {
44 p_campaign_id: campaignId,
45 p_amount_cents: donation.amount_cents,
46 })
47 if (!donation.receipt_sent) {
48 await supabase.functions.invoke('send-donation-receipt', {
49 body: { donationId: donation.id },
50 })
51 await supabase.from('donations').update({ receipt_sent: true }).eq('id', donation.id)
52 }
53 }
54 }
55
56 return new Response(JSON.stringify({ received: true }), { status: 200 })
57})

Customization ideas

Multiple campaigns with campaign management dashboard

Build an admin dashboard at /admin/campaigns with a DataTable listing all campaigns, their progress bars, and Edit/Archive buttons. The Edit button opens a Dialog with a react-hook-form for updating the campaign goal, end date, and description. Add a create campaign flow that sets up a new campaign and generates a shareable donation URL.

Donation matching multiplier

Add a matching_multiplier column to campaigns (e.g., 2 for double-match, 3 for triple-match) and a matching_donor column. When a donation succeeds, display 'Your $50 donation was matched to $100 by Acme Corp' on the success screen. Apply the multiplier in the increment_campaign RPC to update the amount_raised.

Social sharing with dynamic OG images

Add a share Button on the campaign page that generates a pre-filled tweet or LinkedIn post with the campaign progress. Create a Supabase Edge Function that generates a dynamic OG image showing the campaign title, progress percentage, and a CTA. Use this as the og:image meta tag so shared links show a live progress card.

Recurring donation management portal

Add a donor portal at /my-donations where recurring donors can log in with their email, see their recurring donations, change amounts, or cancel. Authentication uses Supabase magic link (email OTP) so donors do not need to create a full account — just verify their email. The portal uses Stripe's Customer Portal for payment method updates.

Donation certificate generator

For donations above a threshold (e.g., $100), generate a personalized certificate using a Supabase Edge Function with pdf-lib. The certificate includes the donor's name, a custom message from the organization, and a unique certificate number stored in the donations table. Donors can download it from their receipt email.

Common pitfalls

Pitfall: Incrementing campaign totals in application code instead of using atomic SQL

How to avoid: Use a SQL UPDATE with an expression: UPDATE campaigns SET amount_raised_cents = amount_raised_cents + $1. PostgreSQL locks the row during the update, making it safe for concurrent increments. Implement this as a Supabase RPC function called from the webhook handler.

Pitfall: Sending receipt emails synchronously inside the webhook handler

How to avoid: Invoke the receipt Edge Function asynchronously using supabase.functions.invoke from within the webhook handler. The invocation queues the receipt generation and returns immediately. Track receipt_sent status in the donations table to prevent duplicates on webhook retries.

Pitfall: Not validating the donation amount on the server side

How to avoid: For preset amounts, validate on the server that the amount matches one of your predefined options. For custom amounts, set a minimum (e.g., 100 cents = $1) and maximum. Return a 400 error for invalid amounts.

Pitfall: Making the donor wall show unverified pending donations

How to avoid: Only show donations with status='succeeded' on the donor wall. Subscribe to the donations table via Supabase Realtime with a filter: .eq('status', 'succeeded'). The wall updates within seconds after the webhook confirms payment.

Best practices

  • Use atomic SQL increments (UPDATE ... SET amount = amount + $1) for campaign totals. Never read-then-write totals in application code.
  • Track receipt_sent as a boolean column on each donation. Check it in the webhook handler before sending to prevent duplicate receipts on webhook retries.
  • Always verify the Stripe webhook signature with constructEventAsync before updating any database records. An unverified webhook could be forged to increment campaign totals without real payments.
  • Support anonymous donations from day one. Add is_anonymous to the donation form. Show 'Anonymous donor' instead of names on the donor wall for these entries.
  • Create Stripe Prices dynamically for recurring donations using arbitrary amounts instead of pre-creating prices for every possible donation value. Clean up unused prices periodically.
  • Add a donor_count column to campaigns that increments alongside amount_raised_cents. This gives you the social proof number without a separate COUNT query on every page load.
  • Set a minimum donation amount of at least $1 (100 cents) to prevent Stripe's minimum charge amount error and to filter out accidental zero-amount submissions.
  • Store the campaign ID in the Stripe PaymentIntent metadata at creation time. The webhook uses this to find the right campaign to increment — do not pass it in a separate database lookup.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a donation system in a Lovable app with Supabase. I need campaign totals to be accurate under concurrent donations. Explain why read-then-write increment patterns fail under concurrency and show me the SQL UPDATE statement that safely increments a counter atomically. Also show how to create this as a Supabase RPC function and call it from a Deno Edge Function using the Supabase JavaScript client.

Lovable Prompt

Add a donation success page at /donate/success. Read the payment_intent query param from the URL. Fetch the donation from Supabase where stripe_payment_intent_id matches. Show: a large animated checkmark (CSS keyframe animation), 'Thank you, {donor_name}!', donation amount, campaign name, and message: 'A PDF receipt has been sent to {donor_email}'. Add a Progress bar showing the updated campaign total. Add share buttons for Twitter and Facebook pre-filled with the message: 'I just donated ${amount} to {campaign_name}! Join me.' Show a Skeleton while loading.

Build Prompt

In Supabase, create a view campaign_stats that returns for each campaign: id, title, goal_cents, amount_raised_cents, donor_count, a percentage column ((amount_raised_cents::float / goal_cents) * 100), days_remaining (DATE_PART days between now and end_date), and is_goal_reached (amount_raised_cents >= goal_cents). Grant SELECT to anon role so it can be fetched without authentication from the campaign page.

Frequently asked questions

Can I accept both one-time and recurring donations in the same form?

Yes, and this guide shows how. The form has a toggle to switch between one-time and monthly donation types. One-time donations use the PaymentIntent flow (collect card → charge once). Recurring donations use the SetupIntent flow (collect card → store payment method → create subscription that bills monthly). The Edge Function handles both flows based on the type parameter.

How do I prevent someone from donating $0 or a negative amount?

Validate the amount inside the Edge Function before calling the Stripe API. Check that amountCents is a positive integer greater than or equal to 100 (the minimum charge in USD). Return a 400 error with a descriptive message if validation fails. Also add Zod validation on the frontend form to show an error before the request is sent.

Are donation receipts legally required?

In the US, organizations must provide written acknowledgment for donations over $250. For tax-deductibility, the receipt must include the organization's name, the donation date and amount, and a statement that no goods or services were provided in exchange (if none were). For donations under $250, receipts are best practice but not strictly required. Check local regulations for your specific jurisdiction and organization type.

How do I handle recurring donors who want to update their donation amount?

Create a portal page where donors authenticate with their email. Show their active recurring donations. Add a Change Amount button that calls the Stripe API to update the subscription's price to a new dynamically created Price object with their chosen amount. Cancel the old subscription item and add the new one in the same subscription update call with proration_behavior set to none.

What happens if a recurring donor's card expires?

Stripe Smart Retries will attempt the payment several times over the following week. Stripe also automatically updates many card numbers via the Account Updater service when banks issue new cards. If all retries fail, Stripe sends invoice.payment_failed and customer.subscription.deleted events. Your webhook handles these to mark the recurring_donor as canceled and update the campaign's active recurring donor count.

Can I set a campaign end date after which donations are not accepted?

Yes. Add an end_date check at the start of the process-donation Edge Function. If the campaign's end_date is in the past or is_active is false, return a 400 error. The frontend shows the closed campaign page differently: replace the donation form with a 'Campaign has ended' message and final totals.

Is there help available for building a larger donation platform?

RapidDev builds production donation platforms in Lovable including multi-campaign management, donor CRM, matching programs, and nonprofit reporting. Contact us if you need a more complete fundraising platform.

How do I display a real-time donor count that updates as people donate?

Use Supabase Realtime to subscribe to the campaigns table. Listen for UPDATE events on the specific campaign row. When the webhook increments donor_count and amount_raised_cents, Realtime pushes the update to all connected browsers. Update the campaign state in your React component with the new values — the Progress bar and donor count update without a page refresh.

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.