Usage & Integration
Practical examples of using Stripe payments in Plainform with checkout, webhooks, and pricing display
Learn how to use Stripe payments in your Plainform application with practical examples.
Creating Checkout Sessions
Create a checkout session to accept payments or start subscriptions. Plainform uses a form-based approach from each pricing card:
export function PricingCard({ priceId, couponId }) {
return (
<form action="/api/stripe/checkout" method="POST">
<input type="hidden" name="priceId" value={priceId} />
{couponId && <input type="hidden" name="couponId" value={couponId} />}
<button type="submit">Buy Now</button>
</form>
);
}Checkout API Route
The checkout route requires a signed-in Clerk user, creates or reuses a Stripe customer, and then creates a Stripe Checkout session:
import { stripe } from '@/lib/stripe/stripe';
import { NextRequest, NextResponse } from 'next/server';
import { currentUser } from '@clerk/nextjs/server';
import { prisma } from '@/lib/prisma/prisma';
import { env } from '@/env';
export async function POST(req: NextRequest) {
const user = await currentUser();
if (!user) {
return NextResponse.redirect(`${env.SITE_URL}/sign-in`, { status: 303 });
}
const email = user.primaryEmailAddress?.emailAddress;
if (!email) {
return NextResponse.json(
{ message: 'Your account is missing an email address.', ok: false },
{ status: 400 }
);
}
const formData = await req.formData();
const priceId = formData.get('priceId')?.toString();
const couponId = formData.get('couponId')?.toString();
if (!priceId) {
return NextResponse.json({ message: 'Invalid request' }, { status: 400 });
}
try {
const price = await stripe.prices.retrieve(priceId);
const mode = price.type === 'recurring' ? 'subscription' : 'payment';
const dbUser = await prisma.user.upsert({
where: { id: user.id },
update: { email },
create: { id: user.id, email },
});
let customerId = dbUser.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email,
metadata: { clerkUserId: user.id },
});
customerId = customer.id;
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
discounts: couponId ? [{ coupon: couponId }] : undefined,
mode,
success_url: `${process.env.SITE_URL}/order?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}`,
automatic_tax: { enabled: true },
// Manual capture for one-time payments
...(mode === 'payment' && {
payment_intent_data: {
capture_method: 'manual',
},
}),
});
return NextResponse.redirect(session.url!, { status: 303 });
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json({ message: 'Checkout failed' }, { status: 500 });
}
}Key points:
- Determines mode (payment vs subscription) from price type
- Requires authentication before purchase
- Links the Stripe customer to the Clerk user with
metadata.clerkUserId - Uses manual capture for one-time payments
- Uses automatic billing for subscriptions
- Redirects to Stripe-hosted checkout page
- Includes automatic tax calculation
Customer Portal
Plainform includes a Stripe Billing Portal route so signed-in users can manage subscriptions from the user menu.
<form action="/api/stripe/portal" method="POST">
<button type="submit">Manage subscription</button>
</form>The portal route:
- Requires a signed-in Clerk user.
- Looks up the local
Userrow by Clerk user ID. - Uses
stripeCustomerIdto create a Stripe Billing Portal session. - Redirects users without a Stripe customer back to
/#buy.
export async function POST(req: NextRequest) {
const user = await currentUser();
if (!user) {
return NextResponse.json(
{ message: 'Unauthorized', ok: false },
{ status: 401 }
);
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { stripeCustomerId: true },
});
if (!dbUser?.stripeCustomerId) {
return NextResponse.redirect(`${env.SITE_URL}/#buy`, { status: 303 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: dbUser.stripeCustomerId,
return_url: `${env.SITE_URL}`,
});
return NextResponse.redirect(portalSession.url, { status: 303 });
}Webhook Handlers
Plainform uses one handler per major Stripe event. The webhook route lives at
app/api/webhooks/stripe/route.ts, while event-specific logic lives in
lib/stripe/webhooks/.
Use the dedicated webhook page for:
- The full event list Plainform handles.
- How
checkout.session.completedbranches between one-time and subscription checkout. - What
capturePaymentIntent()andcancelPaymentIntent()do. - Where to add app-specific subscription logic.
Displaying Products & Pricing
Fetch products from Stripe and display them in your pricing section:
Fetch Products
import Stripe from 'stripe';
export interface IStripeProductWithPrice
extends Omit<Stripe.Product, 'default_price'> {
default_price: Stripe.Price & {
unit_amount: number;
};
}import { env } from '@/env';
export async function getProducts() {
try {
const res = await fetch(`${env.SITE_URL}/api/stripe/products`, {
cache: 'force-cache',
next: { tags: ['stripe/products'] },
});
if (!res.ok) {
return { products: null };
}
return res.json();
} catch (error) {
console.error(error);
return { products: null };
}
}Products API Route
import { stripe } from '@/lib/stripe/stripe';
import { NextResponse } from 'next/server';
export async function GET() {
try {
const products = await stripe.products.list({
active: true,
expand: ['data.default_price'],
});
const activeProducts = products.data.filter((p) => p.active);
const sortedProducts = activeProducts.sort((a, b) => a.created - b.created);
return NextResponse.json({
products: sortedProducts,
ok: true,
});
} catch (error) {
console.error('Products fetch error:', error);
return NextResponse.json(
{ message: 'Failed to fetch products' },
{ status: 500 }
);
}
}Display Pricing
import { getProducts } from '@/lib/stripe/getProducts';
import { getCoupons } from '@/lib/stripe/getCoupons';
import type { IStripeProductWithPrice } from '@/types/StripeInterfaces';
import { PricingCard } from './PricingCard';
export async function Pricing() {
const data = await getProducts();
const couponData = await getCoupons();
if (!data?.products) {
return <div>Unable to load pricing</div>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{data?.products &&
data?.products?.length > 0 &&
data?.products?.map((product: IStripeProductWithPrice) => (
<PricingCard
key={product.id}
name={product.name}
description={product.description}
price={product.default_price.unit_amount / 100}
currency={product.default_price.currency}
priceId={product.default_price.id}
priceType={product.default_price.type}
features={product.marketing_features}
couponId={couponData?.coupon?.id}
discount={couponData?.coupon?.percent_off}
/>
))}
</div>
);
}Key points:
- Products fetched server-side with caching
- Prices divided by 100 (Stripe uses cents)
- Coupons applied if available
- Cached with revalidation tags
Mock Data Fallback
Plainform includes mock products and discounts in locales/en.json that display when no Stripe products are available. This is useful for:
- Development: Test your pricing UI before setting up Stripe products
- Demo purposes: Show pricing structure without live Stripe data
- Fallback: Graceful degradation if Stripe API fails
import locale from '@/locales/en.json';
export async function Pricing() {
const mockProducts = locale?.homePage?.pricingSection?.mockProducts;
const data = await getProducts();
const couponData = await getCoupons();
return (
<div>
{/* Show mock products if no Stripe products */}
{(data?.products?.length === 0 || !data?.products) &&
mockProducts?.map((product, i) => (
<PricingCard
key={i}
name={product.name}
description={product.description}
unitAmount={product.price * 100} // Convert to cents
currency={product.currency}
priceType={product.priceType}
priceId="" // Empty for mock data
amountOff={product?.amountOff ? product.amountOff * 100 : null}
marketingFeatures={product.features.map((f) => ({ name: f }))}
/>
))}
</div>
);
}Mock data structure in locales/en.json:
{
"homePage": {
"pricingSection": {
"mockProducts": [
{
"name": "N\\A",
"description": "Describe who is this plan for.",
"price": 10,
"currency": "usd",
"amountOff": 2,
"priceType": "recurring",
"features": ["Feature one", "Feature two"]
}
],
}
}
}Mock products are automatically hidden once you add real products in Stripe. Update the mock data in locales/en.json to match your pricing structure.
Fetch Coupons
Display active coupons in your pricing section:
import { env } from '@/env';
export async function getCoupons() {
try {
const res = await fetch(`${env.SITE_URL}/api/stripe/coupons`, {
cache: 'force-cache',
next: { tags: ['stripe/coupons'] },
});
if (!res.ok) {
return { coupon: null };
}
return res.json();
} catch (error) {
console.error(error);
return { coupon: null };
}
}Cache Revalidation
Revalidate cached data when Stripe data changes. In Next.js 16, revalidateTag requires a second parameter specifying the cache scope:
switch (event.type) {
case 'product.created':
case 'product.updated':
case 'product.deleted':
revalidateTag('stripe/products', 'max');
break;
case 'coupon.created':
case 'coupon.updated':
revalidateTag('stripe/coupons', 'max');
break;
case 'coupon.deleted':
revalidateTag('stripe/coupons', 'max');
revalidateTag('event', 'max');
break;
case 'checkout.session.completed':
revalidateTag('stripe/coupons', 'max');
revalidateTag('stripe/customers', 'max');
break;
}Why revalidation matters:
- Products and coupons are cached for performance
- Webhooks trigger cache updates when data changes
- Ensures pricing section always shows current data
Testing Payments
Test the payment flow with Stripe test cards:
- Create Checkout: Click "Buy Now" on pricing page
- Use Test Card:
4242 4242 4242 4242 - Complete Checkout: Use any future date and CVC
- Check Webhook: Verify webhook received in Stripe Dashboard
- Verify Subscription: For recurring prices, check the local
Usersubscription fields - Capture Payment: For one-time prices, confirm manual capture behavior in Stripe Dashboard → Payments
In test mode, use Stripe test cards to simulate different scenarios like declined cards, authentication required, etc.
Next Steps
How is this guide ?
Last updated on