Ark Donation Flow with Stripe and CI/CD
Build a donation page with Stripe Checkout and auto-deploy it via GitHub Actions.
Introduction
30 Second Summary
Every donation platform you have ever used has a moment where you click "Donate" and get whisked away to a secure payment page. That seamless handoff is powered by a payment gateway integration running behind the scenes.
In this project, you will build the donation engine for Ark, a platform that connects donors with NGOs. You will create a React donation page with cause cards, wire it to Stripe Checkout for secure payments, and set up a CI/CD pipeline that auto-deploys everything to a live URL.
What You'll Build
A donor-facing donation page for Ark with NGO cause cards, custom donation amounts, a Stripe-powered payment flow, a Node.js backend for session management, and a GitHub Actions pipeline that deploys to Vercel on every push.
By the end of this project, you'll have:
- A React + Vite 8 + Tailwind CSS v4 donation page with NGO cause cards, preset amounts, and custom donation input.
- A Stripe Checkout integration with donate-mode sessions created by a Node.js Express backend, plus server-side payment verification.
- A GitHub Actions CI/CD pipeline that automatically builds and deploys the Ark app to Vercel on every push to main.
- A Stripe webhook endpoint with signature verification for production-grade payment confirmation (Secret Mission).
Are there any prerequisites?
You should be comfortable with React components and JSX, basic Node.js and npm usage, and React Router. You will also need a GitHub account and a Vercel account (both free). No prior Stripe experience is needed.
Scaffold the Ark Project with Vite 8 and Tailwind CSS v4
Every feature in Ark depends on a solid project foundation. Before you can build donation pages or integrate payments, you need a modern React project with fast builds, utility-first styling, and the right dependencies installed.
In this step, you will scaffold a React project using Vite 8, add Tailwind CSS v4 for styling, and install the libraries you will need throughout the project.
In This Step, Get Ready To:
- Create a Vite 8 + React project and install all dependencies.
- Configure Tailwind CSS v4 with its first-party Vite plugin.
- Set up the project folder structure and environment variables.
Create the Vite + React Project and Install Dependencies
Vite 8 uses Rolldown as a unified bundler, replacing the older dual esbuild/Rollup approach. This means 10-30x faster builds compared to previous versions. The scaffolding command creates a ready-to-go React project in seconds.
- Open your terminal and run the following command to scaffold the project.
npm create vite@latest ark-donations -- --template react
What does this command do?
This command uses npm create to run the Vite project scaffolder. The --template react flag tells Vite to set up a React project with the @vitejs/plugin-react v6 plugin, which uses Oxc for React Refresh with no Babel dependency.
- Navigate into the new project directory and install the base dependencies.
cd ark-donations
npm install
Now you need to install the additional libraries for this project. Tailwind CSS v4 has a first-party Vite plugin that is faster than the PostCSS approach. You also need React Router for page navigation and the Stripe React SDK for payment integration.
- Install Tailwind CSS and its Vite plugin.
npm install tailwindcss @tailwindcss/vite
- Install React Router for client-side routing.
npm install react-router
- Install the Stripe React SDK.
npm install @stripe/react-stripe-js @stripe/stripe-js
Why two Stripe packages?
@stripe/stripe-js is the core Stripe.js library that handles secure communication with Stripe's servers. @stripe/react-stripe-js provides React components and hooks that wrap the core library for easier integration with your React app.
If you run into any issues with the installation, ask NextWork for help.
Configure Vite with the Tailwind CSS Plugin
Tailwind CSS v4 takes a CSS-first approach to configuration. There is no tailwind.config.js file needed. Instead, you configure everything directly in your CSS file. The Vite plugin handles automatic content detection, so you do not need a content array either.
- Open vite.config.js in VS Code and replace its contents with the following.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
What does this config do?
The plugins array tells Vite which build-time tools to use. The react() plugin enables JSX support and hot module replacement for React components. The tailwindcss() plugin processes your Tailwind utility classes at build time.
Next, you need to tell your CSS file to load Tailwind. In v4, this is a single import statement that replaces the old @tailwind directives.
- Open src/index.css and replace its entire contents with the following line.
@import "tailwindcss";
Pro tip: CSS-first configuration
Tailwind CSS v4 moved all configuration into CSS. Later in the project, you will use the @theme directive in this same file to define custom Ark brand colors. No JavaScript config file needed.
- Start the development server to verify everything is working.
npm run dev
- Open http://localhost:5173 in your browser.
You should see the default Vite + React welcome page. The styling might look different from what you are used to because Tailwind CSS resets the default browser styles.
I see the Vite + React page
Your project is scaffolded and Tailwind CSS is configured correctly. Keep the dev server running and move on to the next substep.
I see an error
Check the following:
- Make sure you are running the command from inside the ark-donations directory.
- Verify your Node.js version is 20.19 or higher by running node --version. Vite 8 requires Node.js 20.19+ or 22.12+.
- Confirm that vite.config.js has both react() and tailwindcss() in the plugins array.
Still seeing an error?
Share the exact error message with NextWork for troubleshooting help.
Set Up the Project Folder Structure and Environment Variables
A clean folder structure keeps your project organized as it grows. You will also set up environment variables now so that your Stripe API keys are ready to use in later steps.
- Create the following folders inside the src/ directory.
mkdir src/components src/pages src/assets
What are these folders for?
- src/components/ holds reusable UI components like cause cards and headers.
- src/pages/ holds full-page components mapped to routes (donation page, success page, cancel page).
- src/assets/ holds static files like images and icons.
Vite has a built-in convention for environment variables. Any variable prefixed with VITE_ in a .env file is automatically exposed to your frontend code. Variables without the prefix stay hidden from the browser, which is important for keeping secrets safe.
- Create a .env file in the project root (the ark-donations/ directory) with the following content.
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY_HERE
This is a placeholder value for now. You will replace it with your real Stripe test key in the next step.
- Create a .env.example file in the same directory. This file documents which environment variables the project needs, without containing actual values.
VITE_STRIPE_PUBLISHABLE_KEY=
Finally, you need to make sure the .env file never gets committed to Git. Leaking API keys in a public repository is one of the most common security mistakes in software development.
- Open the .gitignore file in your project root. Vite already created one for you.
- Verify that .env is listed. If it is not, add the following line to the file.
.env
Why both .env and .env.example?
The .env file holds your actual keys and is excluded from Git. The .env.example file is committed to Git so that other developers (or your future self) know which variables the project expects. It is a documentation file with empty values.
Your project is scaffolded with all the tools you need. Next up, you will create a Stripe account and plug in your real test API keys.
Create a Stripe Account and Configure Test Keys
Your project is scaffolded and ready for a payment provider. Stripe is the payment gateway that will power Ark's donation flow. It offers a dedicated donate mode built specifically for collecting contributions, and its test mode lets you simulate real transactions without any money changing hands.
In this step, you'll create a Stripe account, grab your test API keys, and wire them securely into your project's environment variables.
In This Step, Get Ready To:
- Create a free Stripe account and activate test mode.
- Add your publishable and secret test keys to the correct .env files.
- Learn the test card numbers you'll use to simulate payments later.
Create a Stripe Account and Enable Test Mode
Stripe has a pay-as-you-go model with no setup fees and no monthly fees. Test mode is completely free and available immediately after account creation. No business verification is needed.
- Go to stripe.com and click Start now.
- Create a free account with your email, name, and password.
- Verify your email address by clicking the confirmation link Stripe sends you.
- Once you're in the Stripe Dashboard, look for the Test mode toggle in the top-right corner of the page.
- Make sure Test mode is enabled. The dashboard should show an orange "Test mode" banner.
- Navigate to Developers, then API keys.
You should see two keys on this page: a Publishable key that starts with pk_test_ and a Secret key that starts with sk_test_. Keep this page open. You'll need both keys in the next substep.
What is test mode?
Stripe's test mode is a sandbox that simulates real transactions without processing actual money. Every API call, checkout session, and webhook behaves exactly like production, but no real charges are made. This lets you build and test your entire payment flow before going live.
Why Stripe Instead of Razorpay?
Stripe has a dedicated submit_type: 'donate' mode in its Checkout Sessions API, purpose-built for donation flows. Razorpay's donation button is a no-code embed that doesn't teach payment integration patterns.
The architecture you'll build (backend creates session, frontend redirects, webhook verifies) is gateway-agnostic. You could swap Stripe for Razorpay in production without changing the architecture. For Indian domestic payments with UPI support, Razorpay would be the stronger choice for a follow-up project.
Add Your Test Keys to the Project
Stripe gives you two types of API keys, and each one belongs in a different place. The publishable key is designed to be used in client-side code. It can only create tokens and cannot make charges. The secret key has full access to your Stripe account and must never leave your server.
- Copy your Publishable key from the Stripe Dashboard.
- Open the .env file in your project root and replace the placeholder value.
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key-here
What does the VITE_ prefix do?
Vite only exposes environment variables that start with VITE_ to your client-side code. Any variable without this prefix stays hidden from the browser. This is a security feature that prevents accidental exposure of server-side secrets.
- Create a server/ directory in your project root. You'll build the Express backend here in the next step.
mkdir server
- Create a server/.env file and add your secret key.
STRIPE_SECRET_KEY=sk_test_your-secret-key-here
- Open your .gitignore file and confirm that both .env files are listed.
Your .gitignore should already include .env from Step 1. Make sure server/.env is also covered. If your .gitignore has a line that says .env (without a path prefix), it will match .env files in any subdirectory automatically.
Never commit your secret key
The publishable key (pk_test_) is safe for client-side code. It can only create tokens and cannot make charges. The secret key (sk_test_) has full access to your Stripe account. If it leaks, anyone could create charges, issue refunds, or access customer data. Always keep it server-side and out of version control.
Learn the Test Card Numbers for Later
Stripe provides special card numbers that simulate different payment outcomes. You won't use these until you test the checkout flow in Step 5, but it's worth knowing them now so you understand how test mode works.
- 4242 4242 4242 4242 simulates a successful payment.
- 4000 0025 0000 3155 triggers 3D Secure authentication (an extra verification step).
- 4000 0000 0000 9995 simulates a declined payment.
For all test cards, use any future expiry date (e.g., 12/34) and any three-digit CVC (e.g., 123).
Pro tip: bookmark the test cards page
Stripe has dozens of test card numbers for simulating specific scenarios like insufficient funds, expired cards, and processing errors. Bookmark the Stripe testing documentation for quick reference when you're debugging payment flows.
Your Stripe account is set up and your API keys are securely configured. Next up, you'll build the donation page UI with NGO cause cards that donors can interact with.
Build the Donation Page UI with NGO Cause Cards
Your Stripe keys are configured and ready to power real payments. Now it's time to build the page your donors will actually see.
A donation page needs to do more than just collect money. It needs to show donors exactly where their contribution goes and make the giving process feel effortless. In this step, you'll build a component-driven donation page with reusable NGO cause cards, preset donation amounts, and a responsive layout styled with Tailwind CSS v4.
In This Step, Get Ready To:
- Create a reusable CauseCard component with preset donation amounts and a custom input field.
- Build the DonationPage with multiple cause cards and set up React Router for page navigation.
- Add the Ark branding header and a responsive grid layout with custom theme colors.
Create the NGO Cause Card Component
Each NGO cause on the donation page needs its own card showing the cause details, a fundraising progress bar, and donation controls. Building this as a reusable component means you can add new causes without duplicating code.
- Create a new file at src/components/CauseCard.jsx.
- Add the following component code:
import { useState } from 'react';
const PRESET_AMOUNTS = [500, 1000, 2500];
export default function CauseCard({ title, description, imageSrc, goal, raised, causeId, onDonate }) {
const [amount, setAmount] = useState('');
const [isCustom, setIsCustom] = useState(false);
const progress = Math.min((raised / goal) * 100, 100);
const handlePresetClick = (preset) => {
setAmount(preset);
setIsCustom(false);
};
const handleCustomChange = (e) => {
setAmount(e.target.value);
setIsCustom(true);
};
const handleDonate = () => {
if (!amount || amount <= 0) return;
onDonate({ causeId, causeName: title, amount: Number(amount) });
};
return (
<div className="bg-white rounded-2xl shadow-md overflow-hidden flex flex-col">
<img src={imageSrc} alt={title} className="w-full h-48 object-cover" />
<div className="p-6 flex flex-col flex-1">
<h3 className="text-xl font-bold text-gray-800 mb-2">{title}</h3>
<p className="text-gray-600 text-sm mb-4 flex-1">{description}</p>
{/* Progress bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-500 mb-1">
<span>${raised.toLocaleString()} raised</span>
<span>${goal.toLocaleString()} goal</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-ark-green rounded-full h-2 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Preset amount buttons */}
<div className="flex gap-2 mb-3">
{PRESET_AMOUNTS.map((preset) => (
<button
key={preset}
onClick={() => handlePresetClick(preset)}
className={`flex-1 py-2 rounded-lg text-sm font-medium border transition-colors ${
amount === preset && !isCustom
? 'bg-ark-green text-white border-ark-green'
: 'bg-white text-gray-700 border-gray-300 hover:border-ark-green'
}`}
>
${preset}
</button>
))}
</div>
{/* Custom amount input */}
<input
type="number"
placeholder="Enter custom amount"
value={isCustom ? amount : ''}
onChange={handleCustomChange}
className="w-full px-4 py-2 border border-gray-300 rounded-lg mb-4 focus:outline-none focus:ring-2 focus:ring-ark-green"
/>
{/* Donate button */}
<button
onClick={handleDonate}
disabled={!amount || amount <= 0}
className="w-full py-3 bg-ark-green text-white font-semibold rounded-lg hover:bg-ark-green-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Donate Now
</button>
</div>
</div>
);
}
What does this component do?
The CauseCard component is a self-contained donation unit. It displays the NGO cause details, tracks fundraising progress with a visual bar, and lets donors pick from preset amounts or type a custom value.
The onDonate callback passes the selected amount and cause details up to the parent page. This keeps the card focused on UI while the parent handles the actual payment logic.
What are those bg-ark-green classes?
You'll notice classes like bg-ark-green and bg-ark-green-dark in the code. These are custom brand colors that you'll define in your CSS file in the last substep of this step. They won't work yet, but they will once you add the @theme configuration.
Build the DonationPage with Cause Cards and Routing
The donation page brings multiple cause cards together in a grid. You also need React Router routes so Stripe can redirect donors to a success or cancel page after checkout.
- Create a new file at src/pages/DonationPage.jsx and add the following code:
import CauseCard from '../components/CauseCard';
// Sample NGO cause data
const CAUSES = [
{
causeId: 'clean-water',
title: 'Clean Water Initiative',
description: 'Provide safe drinking water to rural communities through well construction and water purification systems.',
imageSrc: 'https://images.unsplash.com/photo-1541544741938-0af808871cc0?w=600&h=400&fit=crop',
goal: 50000,
raised: 32000,
},
{
causeId: 'education-for-all',
title: 'Education for All',
description: 'Fund school supplies, teacher training, and scholarships for underprivileged children across South Asia.',
imageSrc: 'https://images.unsplash.com/photo-1497633762265-9d179a990aa6?w=600&h=400&fit=crop',
goal: 75000,
raised: 41000,
},
{
causeId: 'hunger-relief',
title: 'Hunger Relief Program',
description: 'Deliver nutritious meals and build community kitchens in food-insecure neighborhoods.',
imageSrc: 'https://images.unsplash.com/photo-1488521787991-ed7bbaae773c?w=600&h=400&fit=crop',
goal: 30000,
raised: 28500,
},
];
export default function DonationPage() {
const handleDonate = async ({ causeId, causeName, amount }) => {
// Placeholder: Stripe checkout integration goes here in Step 5
console.log(`Donating $${amount} to ${causeName} (${causeId})`);
};
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-800 mb-3">Choose a Cause</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Every donation makes a direct impact. Select a cause you care about and
choose your contribution amount.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{CAUSES.map((cause) => (
<CauseCard key={cause.causeId} {...cause} onDonate={handleDonate} />
))}
</div>
</main>
);
}
Why is handleDonate just a console.log?
The handleDonate function is a placeholder for now. In Step 5, you'll replace this with a real API call to your Express backend that creates a Stripe Checkout Session. Building the UI first lets you verify the layout and interactions before wiring up payments.
Now create the placeholder pages that Stripe will redirect to after checkout.
- Create src/pages/SuccessPage.jsx with a simple placeholder:
export default function SuccessPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-green-50">
<div className="text-center p-8">
<h1 className="text-3xl font-bold text-green-700 mb-4">Thank You!</h1>
<p className="text-gray-600">Your donation was successful. Payment verification coming soon.</p>
</div>
</div>
);
}
- Create src/pages/CancelPage.jsx:
import { Link } from 'react-router';
export default function CancelPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-yellow-50">
<div className="text-center p-8">
<h1 className="text-3xl font-bold text-yellow-700 mb-4">Donation Cancelled</h1>
<p className="text-gray-600 mb-6">No worries! You can try again whenever you're ready.</p>
<Link
to="/"
className="px-6 py-3 bg-ark-green text-white font-semibold rounded-lg hover:bg-ark-green-dark transition-colors"
>
Back to Causes
</Link>
</div>
</div>
);
}
With all three pages created, set up the router so the app knows which page to show at each URL.
- Replace the contents of src/App.jsx with the following router configuration:
import { createBrowserRouter, RouterProvider } from 'react-router';
import DonationPage from './pages/DonationPage';
import SuccessPage from './pages/SuccessPage';
import CancelPage from './pages/CancelPage';
const router = createBrowserRouter([
{ path: '/', element: <DonationPage /> },
{ path: '/success', element: <SuccessPage /> },
{ path: '/cancel', element: <CancelPage /> },
]);
export default function App() {
return <RouterProvider router={router} />;
}
What does createBrowserRouter do?
The createBrowserRouter function from React Router maps URL paths to React components. When a user visits /, they see the DonationPage. When Stripe redirects them to /success or /cancel, they see the corresponding page.
Add the Ark Branding Header and Responsive Layout
The donation page needs a branded header and consistent styling to feel like a real product. Tailwind CSS v4 uses a CSS-first approach for theming, so you'll define custom brand colors directly in your CSS file using @theme instead of a JavaScript config file.
- Create a new file at src/components/Header.jsx:
export default function Header() {
return (
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-ark-green">Ark</h1>
<p className="text-sm text-gray-500">Connect. Donate. Impact.</p>
</div>
<nav className="flex gap-6">
<a href="/" className="text-gray-600 hover:text-ark-green transition-colors font-medium">
Causes
</a>
<a href="/about" className="text-gray-600 hover:text-ark-green transition-colors font-medium">
About
</a>
</nav>
</div>
</header>
);
}
- Update src/App.jsx to include the Header on every page by wrapping the router outlet in a layout:
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router';
import Header from './components/Header';
import DonationPage from './pages/DonationPage';
import SuccessPage from './pages/SuccessPage';
import CancelPage from './pages/CancelPage';
// Layout component wraps all pages with the header
function Layout() {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<Outlet />
</div>
);
}
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ path: '/', element: <DonationPage /> },
{ path: '/success', element: <SuccessPage /> },
{ path: '/cancel', element: <CancelPage /> },
],
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
What is a Layout route?
A layout route wraps child routes with shared UI elements. The Layout component renders the Header once, and the Outlet component renders whichever child route matches the current URL. This way, the header appears on every page without duplicating it.
Now define the custom Ark brand colors so all those bg-ark-green classes actually work.
- Replace the contents of src/index.css with the following:
@import "tailwindcss";
@theme {
--color-ark-green: #16a34a;
--color-ark-green-dark: #15803d;
}
What is @theme in Tailwind CSS v4?
Tailwind CSS v4 uses a CSS-first configuration approach. Instead of a tailwind.config.js file, you define custom design tokens directly in your CSS using @theme. The --color-ark-green variable automatically generates utility classes like bg-ark-green, text-ark-green, and border-ark-green.
Time to see it all come together.
- Start the dev server with npm run dev.
- Open http://localhost:5173 in your browser.
You should see the Ark header at the top, followed by three NGO cause cards in a responsive grid. Each card displays a cause image, description, progress bar, preset donation buttons, and a custom amount input.
- Try clicking the preset amount buttons and typing a custom amount to verify the interaction works.
- Click Donate Now and check the browser console for the logged donation data.
- Resize your browser window to see the cards stack on mobile and expand into columns on larger screens.
What should I see?
The page should show three cause cards in a row on desktop, two columns on medium screens, and a single column on mobile. Each card's progress bar should reflect the raised vs. goal amounts. Clicking Donate Now should log a message like Donating $1000 to Clean Water Initiative (clean-water) in the browser console.
Seeing unstyled content or missing colors?
Make sure your src/index.css file starts with @import "tailwindcss"; and that @tailwindcss/vite is in your vite.config.js plugins array. If the custom colors aren't showing, verify the @theme block is present in your CSS file. Help me debug my Tailwind CSS setup.
Your donation page looks great, but clicking Donate Now only logs to the console. Next up, you'll build the Express backend that creates real Stripe Checkout Sessions and turns those clicks into actual payment flows.
Build the Express Backend with Stripe Checkout Sessions
Your donation page looks great, but clicking "Donate Now" does nothing yet. To actually process a payment, you need a server that talks to Stripe's API on your behalf. The frontend can never hold your secret key, so a backend endpoint must create the Checkout Session and return the payment URL to the browser.
In This Step, Get Ready To:
- Set up an Express server with Stripe and CORS configured.
- Create a POST endpoint that builds Stripe Checkout Sessions in donate mode.
- Create a GET endpoint that verifies payment status for the success page.
Set Up the Express Server with Stripe
The backend lives in its own server/ directory with its own package.json and dependencies. This keeps the frontend and backend cleanly separated.
- Create a server/ directory in the project root.
- Open a terminal, navigate into the server folder, and initialize it.
cd server
npm init -y
- Install the four dependencies the server needs.
npm install express stripe cors dotenv
What does each package do?
- express is the web framework that handles HTTP requests.
- stripe is Stripe's official Node.js SDK for creating sessions and retrieving payment data.
- cors allows your Vite dev server (port 5173) to make requests to the Express server (port 4242) without being blocked by the browser.
- dotenv loads your .env file so your secret key stays out of source code.
- Create server/index.js with the following starter code.
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import Stripe from 'stripe';
// Initialize Stripe with your secret key from server/.env
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Allow requests from the Vite dev server
app.use(cors({ origin: 'http://localhost:5173' }));
// Parse incoming JSON request bodies
app.use(express.json());
app.listen(4242, () => {
console.log('Server running on http://localhost:4242');
});
Why port 4242?
Port 4242 is the convention used in Stripe's own documentation and examples. Using the same port makes it easier to follow Stripe's docs if you need to troubleshoot later.
- Update server/package.json to enable ES module imports by adding the type field.
{
"name": "server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
}
}
What does this do?
Adding "type": "module" tells Node.js to treat .js files as ES modules. This lets you use import and export syntax instead of require().
- Confirm that server/.env exists with your Stripe secret key from Step 2.
STRIPE_SECRET_KEY=sk_test_your-secret-key-here
- Start the server to verify everything loads without errors.
node index.js
What should I see?
Your terminal should print Server running on http://localhost:4242. If you see an error about STRIPE_SECRET_KEY being undefined, double-check that your server/.env file exists and contains your key.
Seeing an error about imports or modules?
If you see Cannot use import statement outside a module, make sure you added "type": "module" to your server/package.json. Help me debug this error.
Create the Checkout Session Endpoint
A Checkout Session is a Stripe object that represents a single payment attempt. Your server creates one, Stripe hosts the payment page, and the donor gets redirected back to your app when they finish. The submit_type: 'donate' option changes the button text on Stripe's page from "Pay" to "Donate".
- Add the following POST endpoint to server/index.js, above the app.listen() call.
// Create a Stripe Checkout Session for a donation
app.post('/create-checkout-session', async (req, res) => {
const { amount, causeName, causeId } = req.body;
try {
const session = await stripe.checkout.sessions.create({
submit_type: 'donate',
mode: 'payment',
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: amount,
product_data: {
name: causeName,
},
},
quantity: 1,
},
],
success_url: 'http://localhost:5173/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:5173/cancel',
});
res.json({ url: session.url });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
What does this endpoint do?
When the frontend sends a POST request with the donation amount, cause name, and cause ID, this endpoint creates a Checkout Session on Stripe's servers. Stripe returns a session object that includes a hosted payment page URL. Your server sends that URL back to the frontend so the browser can redirect the donor to Stripe's secure payment page.
The amount field expects the smallest currency unit. For USD, that means cents. A $10 donation is sent as 1000. The {CHECKOUT_SESSION_ID} in the success URL is a Stripe template variable that Stripe replaces with the actual session ID when redirecting.
Create the Session Verification Endpoint and Start the Server
When a donor completes payment, Stripe redirects them to your success URL with the session ID in the query string. Your success page will call this verification endpoint to confirm the payment actually went through. This is important because anyone could manually type the success URL into their browser without paying.
- Add the following GET endpoint to server/index.js, below the checkout session endpoint and above app.listen().
// Verify a completed Checkout Session
app.get('/verify-session/:sessionId', async (req, res) => {
const { sessionId } = req.params;
try {
const session = await stripe.checkout.sessions.retrieve(sessionId);
res.json({
payment_status: session.payment_status,
amount_total: session.amount_total,
customer_email: session.customer_details?.email,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
What does this endpoint do?
This endpoint takes a session ID from the URL path and asks Stripe directly whether that session's payment was completed. It returns three pieces of information: the payment status (e.g., paid or unpaid), the total amount, and the customer's email. Your success page will use this data to show a verified confirmation message.
Your complete server/index.js should now have the import statements, Stripe initialization, CORS middleware, JSON parser, both endpoints, and the app.listen() call.
I'll try it first
Review your full server/index.js file and make sure all the pieces are in the right order before starting the server.
Let me check my work
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import Stripe from 'stripe';
// Initialize Stripe with your secret key from server/.env
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Allow requests from the Vite dev server
app.use(cors({ origin: 'http://localhost:5173' }));
// Parse incoming JSON request bodies
app.use(express.json());
// Create a Stripe Checkout Session for a donation
app.post('/create-checkout-session', async (req, res) => {
const { amount, causeName, causeId } = req.body;
try {
const session = await stripe.checkout.sessions.create({
submit_type: 'donate',
mode: 'payment',
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: amount, // Amount in cents (e.g., 1000 = $10)
product_data: {
name: causeName, // Displayed on the Stripe Checkout page
},
},
quantity: 1,
},
],
// Stripe replaces {CHECKOUT_SESSION_ID} with the real session ID
success_url: 'http://localhost:5173/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:5173/cancel',
});
// Send the hosted Checkout page URL back to the frontend
res.json({ url: session.url });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Verify a completed Checkout Session
app.get('/verify-session/:sessionId', async (req, res) => {
const { sessionId } = req.params;
try {
// Ask Stripe directly whether this session was paid
const session = await stripe.checkout.sessions.retrieve(sessionId);
res.json({
payment_status: session.payment_status,
amount_total: session.amount_total,
customer_email: session.customer_details?.email,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(4242, () => {
console.log('Server running on http://localhost:4242');
});
- Stop the server if it is still running (press Ctrl+C), then restart it.
node index.js
- Test the checkout endpoint by sending a curl request from a new terminal window.
curl -X POST http://localhost:4242/create-checkout-session \
-H "Content-Type: application/json" \
-d '{"amount": 1000, "causeName": "Clean Water Initiative", "causeId": "water-001"}'
What should I see?
You should see a JSON response containing a url field with a Stripe-hosted checkout page link, something like https://checkout.stripe.com/c/pay/cs_test_.... If you paste that URL into your browser, you will see a real Stripe Checkout page with "Donate" as the button text and "Clean Water Initiative" as the product name.
Your backend is ready to create and verify donations. Next up, you will wire the React frontend to this server so donors can click "Donate Now" and land on a real Stripe payment page.
Connect the Frontend to Stripe Checkout
Your backend can create Stripe Checkout Sessions and verify payments. Your frontend has a beautiful donation page with cause cards. Right now, these two halves don't talk to each other. In this step, you'll wire the donate button to your Express server, redirect donors to Stripe's hosted Checkout page, and build the success and cancel pages that handle what happens after payment.
In This Step, Get Ready To:
- Connect the donate button to the Express backend and redirect to Stripe Checkout.
- Build a success page that verifies payment status server-side.
- Build a cancel page and test the full donation flow with Stripe test cards.
Add the Checkout Trigger to CauseCard
When a donor clicks "Donate Now" on a cause card, the frontend needs to send the donation details to your Express backend. The backend creates a Stripe Checkout Session and returns a URL. The frontend then redirects the browser to that Stripe-hosted page where the donor enters their payment details.
- Open src/pages/DonationPage.jsx.
- Create an handleDonate async function that sends a POST request to your backend and redirects to the Stripe Checkout URL.
// Inside DonationPage.jsx
import { useState } from 'react';
function DonationPage() {
const [loading, setLoading] = useState(false);
const handleDonate = async (amount, causeName, causeId) => {
setLoading(true);
try {
const response = await fetch('http://localhost:4242/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount, causeName, causeId }),
});
const data = await response.json();
// Redirect the browser to Stripe's hosted Checkout page
window.location.href = data.url;
} catch (error) {
console.error('Error creating checkout session:', error);
setLoading(false);
}
};
// ... rest of your component with CauseCard grid
// Pass handleDonate and loading to each CauseCard
}
What does this code do?
The handleDonate function sends the donation amount, cause name, and cause ID to your Express backend. The backend creates a Stripe Checkout Session and returns the session URL. The frontend then redirects the entire browser window to that URL using window.location.href.
The loading state prevents the donor from clicking the button multiple times while the request is in flight.
- Update your CauseCard component to pass the loading prop to the donate button's disabled attribute.
// Inside CauseCard.jsx - update the Donate Now button
<button
onClick={() => onDonate(selectedAmount, title, causeId)}
disabled={loading}
className="w-full bg-indigo-600 text-white py-3 rounded-lg
hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Processing...' : 'Donate Now'}
</button>
What does this code do?
When loading is true, the button text changes to "Processing..." and the button becomes unclickable. The disabled:opacity-50 Tailwind class visually dims the button so the donor knows something is happening.
Build the SuccessPage with Payment Verification
After a successful payment, Stripe redirects the donor back to your success URL. The URL includes a session_id query parameter. Your success page needs to verify this session with your backend before showing a confirmation. Anyone could manually type the success URL into their browser without actually paying, so server-side verification is essential.
- Open src/pages/SuccessPage.jsx and replace the placeholder content with the verification logic.
import { useEffect, useState } from 'react';
import { useSearchParams, Link } from 'react-router';
function SuccessPage() {
const [searchParams] = useSearchParams();
const [session, setSession] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const sessionId = searchParams.get('session_id');
// If there's no session_id in the URL, someone navigated here directly
if (!sessionId) {
setError('No payment session found.');
return;
}
// Verify the payment with the backend
fetch(`http://localhost:4242/verify-session/${sessionId}`)
.then((res) => res.json())
.then((data) => {
if (data.payment_status === 'paid') {
setSession(data);
} else {
setError('Payment was not completed.');
}
})
.catch(() => setError('Could not verify payment.'));
}, [searchParams]);
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600">Something went wrong</h1>
<p className="mt-2 text-gray-600">{error}</p>
<Link to="/" className="mt-4 inline-block text-indigo-600 underline">
Return to donation page
</Link>
</div>
</div>
);
}
if (!session) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-gray-500">Verifying your donation...</p>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-3xl font-bold text-green-600">Thank you for your donation!</h1>
<p className="mt-4 text-lg text-gray-700">
Amount: {(session.amount_total / 100).toFixed(2)}
</p>
<p className="text-gray-500">Payment status: {session.payment_status}</p>
<Link to="/" className="mt-6 inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg">
Make another donation
</Link>
</div>
</div>
);
}
export default SuccessPage;
What does this code do?
The useSearchParams() hook from React Router extracts the session_id from the URL that Stripe redirected to. The component then calls your /verify-session/:sessionId endpoint to check with Stripe whether the payment actually went through.
The component handles three states: loading (while verifying), error (if the session is invalid or unpaid), and success (showing the confirmed donation amount).
Pro tip: never trust the URL
Server-side verification is what separates a toy payment integration from a production one. Without it, anyone could bookmark the success URL and pretend they donated. Your backend calls Stripe directly to confirm the payment status, which is the only trustworthy source.
Build the CancelPage and Test the Full Flow
If a donor clicks the back button on the Stripe Checkout page, Stripe redirects them to your cancel URL. This page should be friendly and encourage the donor to try again.
- Open src/pages/CancelPage.jsx and replace the placeholder content.
import { Link } from 'react-router';
function CancelPage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-800">Donation cancelled</h1>
<p className="mt-2 text-gray-600">
No worries! Your payment was not processed.
</p>
<p className="mt-1 text-gray-500">
Every contribution makes a difference when you're ready.
</p>
<Link
to="/"
className="mt-6 inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700"
>
Back to causes
</Link>
</div>
</div>
);
}
export default CancelPage;
What does this code do?
The cancel page is intentionally simple. It reassures the donor that no money was charged and provides a clear path back to the donation page. No verification is needed here because no payment was attempted.
Now it's time to test the entire donation flow end to end.
- Open a terminal in the server/ directory and start the Express server.
node index.js
- Open a second terminal in the project root and start the Vite dev server.
npm run dev
- Open http://localhost:5173 in your browser.
- Select a cause card and enter a donation amount (or click a preset amount).
- Click Donate Now.
Your browser redirects to the Stripe Checkout page. This is Stripe's hosted payment form where the donor enters their card details.
- Enter the test card number 4242 4242 4242 4242.
- Enter any future expiry date (e.g., 12/30) and any 3-digit CVC (e.g., 123).
- Click Donate on the Stripe Checkout page.
What should I see?
After clicking Donate, Stripe processes the test payment and redirects you back to your success page. You should see a green "Thank you for your donation!" heading, the donation amount, and a payment status of paid.
- Test the cancel flow by going back to the donation page, clicking Donate Now again, and clicking the back arrow on the Stripe Checkout page.
You should land on your cancel page with the friendly "Donation cancelled" message and a link back to the causes.
Getting a CORS error in the console?
Make sure your Express server has CORS enabled for http://localhost:5173 (the Vite dev server origin). Check that your server/index.js includes app.use(cors({ origin: 'http://localhost:5173' })). Also confirm the Express server is running on port 4242. Help me debug my CORS error.
Stripe Checkout page shows an error?
Double-check that your server/.env file contains your Stripe test secret key (starts with sk_test_). Also verify that the amount being sent is in the smallest currency unit (e.g., paise for INR, cents for USD). A donation of 500 rupees should be sent as 50000. Help me debug my Stripe Checkout error.
Your donation flow is fully functional. Donors can pick a cause, enter an amount, pay through Stripe, and see a verified confirmation. Next up, you'll set up a CI/CD pipeline so every code change automatically deploys your app to a live URL.
Set Up GitHub Actions CI/CD Pipeline to Vercel
Your donation flow works locally. Donors can select a cause, complete payment through Stripe Checkout, and land on a verified success page. Now it is time to ship it to a live URL that anyone can visit.
Right now, deploying means manually running commands every time you make a change. A CI/CD pipeline automates this. Every time you push code to main, GitHub Actions will build your app and deploy it to Vercel automatically.
In This Step, Get Ready To:
- Push the Ark project to a GitHub repository.
- Link the project to Vercel and configure deployment credentials.
- Create a GitHub Actions workflow that auto-deploys to Vercel on every push to main.
Initialize the Git Repository and Push to GitHub
Before you can automate deployments, your code needs to live in a GitHub repository. The CI/CD pipeline will watch this repository for changes and trigger deployments.
- Open a terminal and make sure you are in the project root directory (ark-donations/), not inside server/.
- Initialize a Git repository.
git init
- Open your .gitignore file and make sure it includes the following entries.
node_modules/
.env
server/.env
server/node_modules/
.vercel/
Why exclude these files?
The .env files contain your Stripe API keys. Pushing them to GitHub would expose your credentials publicly. The node_modules/ folders contain installed packages that can be recreated from package.json. The .vercel/ folder contains project-specific IDs that you will configure as GitHub secrets instead.
- Stage all files and create your initial commit.
git add .
git commit -m "Initial Ark donation page"
- Go to github.com and create a new repository.
- Name it ark-donations.
- Leave it as a public repository and do not initialize it with a README.
- Click Create repository.
- Copy the repository URL that GitHub shows you, then add the remote and push.
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/ark-donations.git"]]
git push -u origin main
Getting a "src refspec main does not match" error?
Your default branch might be named master instead of main. Run git branch -M main to rename it, then try the push again. Help me fix my Git push.
Link the Project to Vercel and Get Deployment Credentials
GitHub Actions needs permission to deploy to your Vercel account. You will link your project to Vercel locally, then store the credentials as GitHub secrets so the pipeline can use them.
- Install the Vercel CLI globally.
npm install --global vercel@latest
- Log in to your Vercel account from the terminal.
vercel login
Follow the prompts to authenticate with your Vercel account. You can choose email, GitHub, or another method.
- Link your project to Vercel.
vercel link
What does vercel link do?
This command connects your local project to a Vercel project. It creates a .vercel/ folder containing a project.json file with your orgId and projectId. You will need both of these values in a moment.
- Open .vercel/project.json and copy the values for orgId and projectId. You will need them shortly.
Next, you need a Vercel access token so GitHub Actions can deploy on your behalf.
- Go to vercel.com/account/tokens in your browser.
- Click Create to generate a new token.
- Give it a descriptive name like ark-github-actions.
- Copy the token. You will not be able to see it again after leaving this page.
Now add all three credentials as secrets in your GitHub repository.
- Go to your ark-donations repository on GitHub.
- Click Settings.
- In the left sidebar, click Secrets and variables, then Actions.
- Click New repository secret and add each of the following three secrets one at a time:
- Name: VERCEL_TOKEN with the access token you just created.
- Name: VERCEL_ORG_ID with the orgId value from .vercel/project.json.
- Name: VERCEL_PROJECT_ID with the projectId value from .vercel/project.json.
Why use GitHub secrets instead of environment variables?
GitHub secrets are encrypted and never exposed in logs or to forks. The workflow can read them at runtime, but no one can view the raw values after they are saved. This is the standard way to store deployment credentials in CI/CD pipelines.
Create the GitHub Actions Workflow File
A GitHub Actions workflow is a YAML file that defines what happens when code is pushed to your repository. Your workflow will build the Ark frontend and deploy it to Vercel every time you push to main.
- Create the workflow directory structure in your project root.
mkdir -p .github/workflows
- Create a new file at .github/workflows/deploy.yml and add the following workflow.
name: Deploy to Vercel
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment info
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build project artifacts
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy to Vercel
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
What does each step in this workflow do?
- actions/checkout@v4 clones your repository into the GitHub Actions runner so the pipeline has access to your code.
- vercel pull downloads your Vercel project settings and environment variables into the runner.
- vercel build --prod builds your Vite project locally on the runner, producing optimized production files.
- vercel deploy --prebuilt --prod uploads the pre-built output to Vercel without rebuilding on their servers. This makes deploys faster and more predictable.
Pro tip: why prebuilt deploys?
The --prebuilt flag tells Vercel to skip its own build step and use the artifacts you already built. This gives you full control over the build process in your pipeline. If the build fails, you will see the error in GitHub Actions, not buried in Vercel's logs.
- Commit and push the workflow file to trigger the pipeline.
git add .
git commit -m "Add GitHub Actions deploy workflow"
git push
- Go to your repository on GitHub.
- Click the Actions tab at the top of the page.
- Click on the running workflow to watch the pipeline execute each step.
I see a green checkmark
Your pipeline ran successfully. The deploy step output includes a Vercel URL where your Ark donation page is now live. Open that URL in your browser to see your deployed app.
I see a red X
Click on the failed job to see which step broke. Common issues:
- "Error: No token found" means your VERCEL_TOKEN secret is missing or misspelled. Double-check the secret name in your repository settings.
- "Project not found" means your VERCEL_ORG_ID or VERCEL_PROJECT_ID values do not match what is in .vercel/project.json. Re-check the values and update the secrets.
- Build errors usually come from missing dependencies or import issues. Check the build log for the specific error message. Help me debug my GitHub Actions workflow.
Your Ark donation page is now live on the internet with a fully automated deployment pipeline. Every time you push a change to main, GitHub Actions builds your app and ships it to Vercel without you lifting a finger. Next up, you will take on a secret mission to add production-grade webhook verification to your payment flow.
Secret mission
Add a Stripe Webhook Endpoint for Server-Side Payment Verification
Your donation flow works, but it has a gap. What happens if a donor completes payment on Stripe's page and then closes their browser before the redirect? The success page never loads, and your app never knows the payment happened. Webhooks solve this by having Stripe call YOUR server directly to confirm every completed payment.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Stripe test mode (free), Vercel's free Hobby plan, and GitHub's free tier, so there are no ongoing costs for the cloud services.
Resources you used:
- Stripe account (test mode) with publishable and secret API keys.
- Vercel project linked to your Ark donations app.
- GitHub repository with Actions secrets (VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID).
- Local project files in the ark-donations directory (frontend and server/ backend).
- Vercel CLI installed globally.
- Environment variable files (.env and server/.env) containing Stripe API keys.
Keep everything running
No action needed. Choose this if you are still actively building on the Ark project or plan to add more features soon.
- Your Vercel deployment stays live at its URL. The Hobby plan is free, so there is no cost.
- Your Stripe account remains in test mode. No charges are incurred in test mode.
- Your GitHub Actions workflow continues to run on every push to main. GitHub's free tier includes 2,000 Actions minutes per month.
- Your local .env files with API keys stay on your machine. Make sure they remain in .gitignore.
Pause - I'll come back to this later
Shut down running processes to free up memory on your machine. Your cloud resources are all on free tiers, so they can stay as-is.
- Stop the Vite dev server and the Express server if they are still running in your terminal (press Ctrl + C in each terminal window).
- If you ran stripe listen for the secret mission, stop that process as well.
- Leave your Vercel project, GitHub repo, and Stripe account as they are. You can pick up right where you left off.
- Keep your local project folder intact so you do not have to re-clone or reconfigure environment variables.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up everything.
- Stop all running processes (Vite dev server, Express server, and stripe listen if applicable).
- Delete the Vercel project. Go to your Vercel dashboard, select the Ark project, navigate to Settings, scroll to the bottom, and click Delete Project.
- Delete the GitHub repository. Go to your repository on GitHub, click Settings, scroll to the Danger Zone section, and click Delete this repository.
- Revoke your Vercel access token. Go to vercel.com/account/tokens and delete the token you created for GitHub Actions.
- Roll your Stripe API keys. In the Stripe Dashboard, go to Developers > API keys and roll both your publishable and secret test keys. This invalidates the old keys in case they were accidentally exposed.
- Optionally, uninstall the Vercel CLI by running npm uninstall -g vercel.
- Delete the local project folder from your machine.
Mission accomplished
That's a Wrap!
Nice work! You just built the payment engine for Ark, complete with a donor-facing UI, real payment infrastructure powered by Stripe, and an automated deployment pipeline. This is the kind of foundation that separates a concept from a product.
Here's what you accomplished:
- Built a React + Vite 8 + Tailwind CSS v4 donation page with NGO cause cards, preset amounts, and custom donation inputs.
- Created a Node.js Express backend that creates Stripe Checkout Sessions with submit_type: 'donate' and verifies payment status server-side to prevent spoofed success page visits.
- Set up a GitHub Actions CI/CD pipeline that automatically builds and deploys the Ark frontend to Vercel on every push to main.
- Added a Stripe webhook endpoint with cryptographic signature verification for production-grade, server-side payment confirmation.
Ready to quiz yourself?