Build a crowdfunding platform where users can browse campaigns, pledge money through Stripe Checkout, and watch funding progress update in real time. The stack: Strapi 5 as the backend, Next.js 16 for the frontend, and Stripe for payment processing. If you follow along with the code, expect about 45 minutes from start to finish.
Most tutorials on how to build a crowdfunding platform with Strapi either stop at a marketing mockup with no real payment code or hand-roll a payment flow that skips signature verification entirely.
Neither is useful if you're trying to ship something that actually works. This tutorial covers the full loop: campaign content modeling in Strapi, a custom Stripe Checkout endpoint that validates amounts server-side, webhook verification that confirms real payments, and a Next.js 16 frontend that ties it all together.
By the end, you'll have a campaign list page, a detail page with a pledge button and progress bar, and a Stripe-confirmed pledge that increments the campaign's raised amount. Everything runs locally, everything talks to Stripe's test mode, and nothing is left as an exercise for the reader.
In brief:
- Create Campaign and Pledge Collection Types in Strapi 5 for campaign content and pledge tracking.
- Build a server-side Stripe Checkout endpoint that validates pledge amounts before creating a payment session.
- Verify Stripe webhooks with
constructEvent()before marking pledges as completed and updating funding totals. - Render campaign pages in Next.js 16 with Incremental Static Regeneration so funding progress stays current without redeployment.
What You'll Build and What You Need
The architecture is straightforward. Strapi exposes campaigns and pledges via its REST API. Next.js 16 renders them. Stripe Checkout handles money. A webhook closes the loop by updating the pledge status and the campaign's funding total in Strapi. This follows a headless CMS architecture, where the backend is purely an API layer and the frontend is free to consume it however it wants.
A visitor lands on the campaign list page, clicks into a campaign, fills in a pledge amount and email, and gets redirected to Stripe's hosted Checkout page. After completing the payment with a test card, Stripe fires a webhook back to Strapi, which marks the pledge as completed and increments the campaign's raised amount.
You need Node.js installation (odd-numbered releases aren't supported by Strapi). You need a Stripe test account, which gives you API keys without processing real charges.
The tutorial assumes familiarity with TypeScript and the Next.js App Router, so you should be comfortable with React Server Components and async data fetching. Strapi 5 and Next.js 16 are the specific versions used throughout. Grab your code editor of choice and three terminal windows, because you'll be running Strapi, Next.js, and the Stripe CLI simultaneously.
Set Up the Strapi Backend
Install Strapi 5 and Bootstrap the Project
Run the following command to scaffold a new project:
npx create-strapi@latest backendThe interactive prompts will ask about your setup. Skip the login step, and choose SQLite for local development. Skip --quickstart here. The article uses the Strapi 5 CLI flow.
Once the installation finishes, start the dev server:
cd backend
npm run developOpen http://localhost:1337/admin and create your first admin user. This account is only for the Admin Panel. It has nothing to do with frontend API access.
Create the Campaign Collection Type
Open the Content-Type Builder in the Admin Panel and create a new Collection Type called Campaign. Add these fields:
title(Text, required)slug(Unique Identifier (UID), sourced fromtitle). ThetargetFieldparameter connects the UID to the title, and a Regenerate button auto-populates the slug in the Content Manager.description(Rich text)goalAmount(Decimal)raisedAmount(Decimal, default value0)deadline(Date)coverImage(Single media, restricted to images)status(Enumeration with values:active,funded,closed)
Save the content type. Strapi will restart the server automatically.
The three status values map to distinct campaign lifecycle stages. active means the campaign is accepting pledges. funded means the raised amount has met or exceeded the goal, and the webhook handler sets this automatically. closed means the deadline has passed or the campaign creator ended the campaign manually through the Admin Panel.
The raisedAmount field starts at 0 and gets incremented by the webhook handler each time a pledge is confirmed. You could compute that by aggregating all pledges at query time, but that comes with a cost: every page load would need to sum every pledge record, and that query gets slower as pledge counts grow. Storing the running total directly on the campaign keeps reads fast.
Create the Pledge Collection Type
Create a second Collection Type called Pledge with these fields:
amount(Decimal, required)supporterEmail(Email)stripeSessionId(Text, unique). This field correlates webhook events to Strapi records.status(Enumeration:pending,completed,failed)campaign(Relation: many-to-one with Campaign)
Why a separate Collection Type instead of a component embedded in Campaign? Three reasons:
- pledges need their own audit trail
- you'll query them independently when a webhook fires
- updating a single pledge record by
stripeSessionIdis much cleaner than digging through a component array
Set Permissions and Generate an API Token
Navigate to Settings, then Users & Permissions Plugin, then Roles, and click the Public role. Under Campaign permissions, enable find and findOne. Leave everything else unchecked. Save. This lets unauthenticated requests read campaigns but nothing else. Strapi exposes these through its REST API endpoints, and the Public role controls exactly which operations are available without authentication. For more on how this works, see Strapi permissions.
Next, go to Settings, then Global Settings, then API tokens. Create a new token with the Custom type, granting only find and findOne on Campaign. Set the duration to Unlimited for local development. Copy the token immediately. Strapi only shows it once.
Keep pledge writes off the public role. The frontend will route all pledge writes through a custom Strapi endpoint that you'll build next.
Build the Stripe Checkout Endpoint in Strapi
The Checkout session should live on the server side rather than in the browser because you control the price server-side. If the amount calculation happened on the frontend, a user could modify the request body and pledge $0.01 instead of $50.
Install the Stripe SDK and Add Environment Variables
From the backend directory:
npm install stripeAdd two variables to your .env file. Pull these from the Stripe Dashboard under Developers, then API Keys (for the secret key) and the Stripe CLI output (for the webhook secret, covered later):
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Add a Custom Route for the Checkout Session
Create a custom route file that loads before the core routes. The numeric prefix controls load order:
// src/api/pledge/routes/01-custom-pledge.ts
export default {
routes: [
{
method: 'POST',
path: '/pledges/checkout',
handler: 'pledge.createCheckoutSession',
config: {
auth: false,
},
},
],
};Setting auth: false is necessary because Stripe has no mechanism to present a Strapi JWT when calling this endpoint. The Checkout Sessions API manages the checkout lifecycle, while payment security and PCI compliance are a shared responsibility between Stripe and your business.
Implement the Checkout Session Controller
Here's the full controller. Note that Strapi 5 uses documentId (a 24-character alphanumeric string) instead of the numeric id from v4. This is a common gotcha when adapting older examples.
// src/api/pledge/controllers/pledge.ts
import { factories } from '@strapi/strapi';
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
export default factories.createCoreController('api::pledge.pledge', ({ strapi }) => ({
async createCheckoutSession(ctx) {
const { campaignId, amount, supporterEmail } = ctx.request.body;
// Validate the campaign exists using documentId
const campaign = await strapi.documents('api::campaign.campaign').findOne({
documentId: campaignId,
});
if (!campaign) {
return ctx.badRequest('Campaign not found');
}
// Amount must be a positive integer in cents
const amountInCents = Math.round(Number(amount) * 100);
if (!amountInCents || amountInCents < 100) {
return ctx.badRequest('Amount must be at least $1.00');
}
const session = await stripe.checkout.sessions.create({
customer_email: supporterEmail,
line_items: [
{
price_data: {
currency: 'usd',
product_data: { name: `Pledge to ${campaign.title}` },
unit_amount: amountInCents,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${process.env.FRONTEND_URL}/pledge/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pledge/cancel`,
metadata: { campaignDocumentId: campaignId },
});
// Create a pending pledge before redirecting
await strapi.documents('api::pledge.pledge').create({
data: {
amount: amountInCents / 100,
supporterEmail,
stripeSessionId: session.id,
status: 'pending',
campaign: { documentId: campaignId },
},
});
ctx.body = { url: session.url };
},
}));The metadata field in the Stripe session is the thread that connects checkout to webhook processing. When you set metadata: { campaignDocumentId: campaignId }, that key-value pair travels with the session through Stripe's system and arrives intact in the checkout.session.completed webhook payload. That is how the webhook handler knows which campaign's raisedAmount to increment, even though Stripe itself has no knowledge of your Strapi data model.
The two-step write pattern here is intentional:
- the pledge is created in a
pendingstate before the user redirects to Stripe - if the user closes their browser after paying but before the success redirect fires, the webhook still has a record to update
- without this, you'd lose completed payments
For more on structuring custom controllers and secure API keys, those guides go deeper.
Handle Stripe Webhooks to Confirm Pledges
Without webhooks, you have no reliable way to know if a user actually paid. The successful redirect is not proof of payment. It's just a browser redirect that anyone could hit directly.
Add the Webhook Route
Add a second route to the same custom route file. The critical piece here is parseBody: false, which tells Strapi not to parse the request body before it reaches your controller. Stripe's signature verification requires the raw body, and any parsing breaks the signature check.
// Add to src/api/pledge/routes/01-custom-pledge.ts
{
method: 'POST',
path: '/pledges/webhook',
handler: 'pledge.handleWebhook',
config: {
auth: false,
parseBody: false,
},
},You may need to adjust Strapi's body middleware configuration depending on your version and use case.
Verify the Signature and Handle the Event
Add the handleWebhook action to your pledge controller. In Strapi 5, the official documentation does not clearly document a supported raw request body access pattern. The raw body and signature header are passed to webhook verification via constructEvent().
async handleWebhook(ctx) {
const signature = ctx.request.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
ctx.request.rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return ctx.badRequest(`Webhook signature verification failed: ${err.message}`);
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const pledges = await strapi.documents('api::pledge.pledge').findMany({
filters: { stripeSessionId: { $eq: session.id } },
populate: ['campaign'],
});
if (pledges.length > 0) {
const pledge = pledges[0];
// Skip if already processed (idempotency guard)
if (pledge.status === 'completed') {
ctx.body = { received: true };
return;
}
await strapi.documents('api::pledge.pledge').update({
documentId: pledge.documentId,
data: { status: 'completed' },
});
// Increment the campaign's raised amount
const campaign = pledge.campaign;
const newTotal = (campaign.raisedAmount || 0) + pledge.amount;
await strapi.documents('api::campaign.campaign').update({
documentId: campaign.documentId,
data: {
raisedAmount: newTotal,
status: newTotal >= campaign.goalAmount ? 'funded' : campaign.status,
},
});
}
}
ctx.body = { received: true };
},The handler returns 400 on signature mismatch and 200 for everything else. Stripe retries on non-2xx responses, so returning 200 even for unhandled event types is the correct behavior. For more on payment gateways with Strapi, the linked guide covers additional patterns.
The idempotency check near the top of the event handler is worth calling out:
- Stripe may deliver the same webhook event more than once, especially during network instability or retry cycles
- without the
pledge.status === 'completed'guard, a duplicatecheckout.session.completedevent would incrementraisedAmounta second time - that inflates the campaign total
Treat webhook handlers as if duplicate events are going to happen, because eventually they do.
Test the Webhook Locally with the Stripe CLI
Install the Stripe CLI, authenticate with stripe login, then forward events to your local Strapi instance with stripe listen --forward-to localhost:1337/api/pledges/webhook. The CLI prints a whsec_... signing secret. Copy that into your .env as STRIPE_WEBHOOK_SECRET.
stripe listen --forward-to localhost:1337/api/pledges/webhookSet Up the Next.js 16 Frontend
With the Strapi backend and Stripe integration complete, the frontend needs a Next.js 16 project wired to the Strapi API with proper image handling and authentication.
Bootstrap a Next.js 16 App
npx create-next-app@latest frontend --typescript --tailwind --appThe App Router is the default. React Server Components handle the campaign list and detail pages. The pledge form will be a Client Component since it needs interactivity.
Configure Environment Variables and a Strapi Fetcher
Add two variables to .env.local for your Strapi URL and API token, for example for local development:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_token_hereBuild a small helper to wrap fetch() with the API token:
// lib/strapi.ts
export async function fetchStrapi(path: string, options?: RequestInit) {
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api${path}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
...options?.headers,
},
});
if (!res.ok) throw new Error(`Strapi error: ${res.status}`);
return res.json();
}Enable the image domain for Strapi-hosted uploads in next.config.ts so campaign cover images render through the Image component:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'localhost',
port: '1337',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;For more on using Strapi and Next.js 16 together and fetching from Strapi, those guides cover additional patterns.
With the project scaffolded and the Strapi fetcher in place, you can start building the actual pages.
Build the Campaigns List and Detail Pages
The campaign list and detail pages are async Server Components that fetch data at request time and regenerate on a 60-second interval through ISR.
Render the Campaigns List on the Home Page
app/page.tsx is an async Server Component by default. No directive needed:
// app/page.tsx
import Link from 'next/link';
import Image from 'next/image';
import { fetchStrapi } from '@/lib/strapi';
export const revalidate = 60;
export default async function HomePage() {
const { data } = await fetchStrapi(
'/campaigns?populate=coverImage&filters[status][$eq]=active'
);
return (
<main className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
{data.map((campaign: any) => (
<Link key={campaign.documentId} href={`/campaigns/${campaign.slug}`}
className="border rounded-lg overflow-hidden hover:shadow-lg">
{campaign.coverImage && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${campaign.coverImage.url}`}
alt={campaign.title}
width={400} height={250}
className="w-full object-cover"
/>
)}
<div className="p-4">
<h2 className="text-xl font-bold">{campaign.title}</h2>
<p className="text-sm text-gray-600 mt-2">
${campaign.raisedAmount} raised of ${campaign.goalAmount}
</p>
<span className="text-blue-600 mt-2 inline-block">View Campaign →</span>
</div>
</Link>
))}
</main>
);
}Note the Strapi 5 response shape: campaign.title, not campaign.attributes.title. The v5 response is flat. For image optimization strategies, consider responsive sizing and lazy loading to improve performance.
Build the Campaign Detail Page with a Progress Bar
In Next.js 16, params is a Promise. Synchronous access was deprecated in v15 and fully removed in v16, so you access properties only after awaiting it:
// app/campaigns/[slug]/page.tsx
import { fetchStrapi } from '@/lib/strapi';
import { notFound } from 'next/navigation';
import PledgeForm from '@/components/PledgeForm';
export const revalidate = 60;
export default async function CampaignPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
const { data } = await fetchStrapi(
`/campaigns?filters[slug][$eq]=${slug}&populate=*`
);
if (!data || data.length === 0) notFound();
const campaign = data[0];
const progress = Math.min((campaign.raisedAmount / campaign.goalAmount) * 100, 100);
const deadline = new Date(campaign.deadline).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
return (
<article className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold">{campaign.title}</h1>
<p className="text-gray-500 mt-1">Deadline: {deadline}</p>
<div className="w-full bg-gray-200 rounded-full h-4 mt-4">
<div className="bg-green-500 h-4 rounded-full" style={{ width: `${progress}%` }} />
</div>
<p className="mt-2">${campaign.raisedAmount} raised of ${campaign.goalAmount}</p>
<div className="mt-6 prose" dangerouslySetInnerHTML={{ __html: campaign.description }} />
<PledgeForm campaignDocumentId={campaign.documentId} />
</article>
);
}Add the Pledge Form and Trigger Stripe Checkout
The pledge form is the only Client Component in the app because it manages form state, loading indicators, and the redirect to Stripe's hosted checkout page.
Build the Pledge Form as a Client Component
// components/PledgeForm.tsx
'use client';
import { useState } from 'react';
export default function PledgeForm({ campaignDocumentId }: { campaignDocumentId: string }) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/pledges/checkout`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaignId: campaignDocumentId,
amount: Number(formData.get('amount')),
supporterEmail: formData.get('email'),
}),
}
);
if (!res.ok) throw new Error('Checkout session failed');
const { url } = await res.json();
window.location.href = url;
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setIsLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-8 space-y-4 border-t pt-6">
<h3 className="text-lg font-semibold">Back This Campaign</h3>
<input name="amount" type="number" min="1" required placeholder="Pledge amount (USD)"
className="w-full border rounded p-2" />
<input name="email" type="email" required placeholder="Your email"
className="w-full border rounded p-2" />
{error && <p role="alert" className="text-red-600">{error}</p>}
<button type="submit" disabled={isLoading}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50">
{isLoading ? 'Redirecting...' : 'Pledge Now'}
</button>
</form>
);
}Note that the amount is sent as a dollar value (for example, 25), not in cents. The Strapi controller handles the cents conversion server-side, which keeps the frontend simple and prevents the client from controlling the final amount sent to Stripe. For more on form handling, the linked guide covers validation and error states in detail.
Submit to the Strapi Endpoint and Redirect
On successful submission, the component redirects to Stripe's hosted checkout page via window.location.href. Create two simple pages to handle the return:
// app/pledge/success/page.tsx
import Link from 'next/link';
export default function PledgeSuccessPage() {
return (
<main className="max-w-md mx-auto p-8 text-center">
<h1 className="text-2xl font-bold">Thank You!</h1>
<p className="mt-4 text-gray-600">
Your pledge has been received. The campaign page will update shortly.
</p>
<Link href="/" className="mt-6 inline-block text-blue-600 hover:underline">
Browse more campaigns
</Link>
</main>
);
}// app/pledge/cancel/page.tsx
import Link from 'next/link';
export default function PledgeCancelPage() {
return (
<main className="max-w-md mx-auto p-8 text-center">
<h1 className="text-2xl font-bold">Pledge Not Processed</h1>
<p className="mt-4 text-gray-600">
You were not charged. You can return to the campaigns list and try again.
</p>
<Link href="/" className="mt-6 inline-block text-blue-600 hover:underline">
Back to campaigns
</Link>
</main>
);
}These map directly to the success_url and cancel_url configured in the Checkout session.
Test the Full Pledge Flow
Open three terminals. Run npm run develop in the backend directory, npm run dev in the frontend directory, and stripe listen --forward-to localhost:1337/api/pledges/webhook in any directory. Open the home page, click into a campaign, enter a pledge amount and email, and submit the form. On the Stripe Checkout page, use the test card number 4242 4242 4242 4242 with any future expiration date and any CVC. Complete the payment. Watch the Stripe CLI terminal confirm the webhook delivery. Refresh the campaign detail page. Within the 60-second ISR window, the raised amount should reflect the new pledge.
If it doesn't update, check three things:
- the
STRIPE_WEBHOOK_SECRETin.envmatches thewhsec_...value printed bystripe listen - the webhook route has
parseBody: falsein its config - the pledge's
stripeSessionIdis being stored before the redirect to Stripe
If you're still stuck, verify that the Stripe CLI session is active and listening for events, and remember that with time-based ISR (for example, revalidate: 60), Next.js 16 may continue serving a cached version until the revalidation window expires, but webhook-triggered on-demand revalidation is intended to invalidate the cached page immediately. A hard refresh past the revalidation window will show the updated total.
For production deployment, consult the current official Strapi documentation for up-to-date instructions.
Ship It and Extend the Platform
You now have a working crowdfunding platform running locally: campaigns modeled in Strapi with goals, deadlines, and funding status; a server-side Stripe Checkout endpoint that prevents amount tampering; webhook-confirmed pledges that update the campaign's raised amount automatically; and a Next.js 16 frontend with ISR that keeps funding progress fresh.
The security layer, specifically server-side amount validation and webhook signature verification with constructEvent(), is what separates a production-grade payment flow from a demo. From here, three concrete next steps are worth exploring: Stripe Connect for campaign creator payouts, user authentication for pledge history, or an Admin Panel widget that flags campaigns approaching their deadline without hitting their goal. Strapi provides additional resources for building Stripe integrations, including official blog posts and community plugins.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.