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:

components/pricing/PricingCard.tsx (simplified)
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:

app/api/stripe/checkout/route.ts
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.

components/user/UserMenu.tsx (simplified)
<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 User row by Clerk user ID.
  • Uses stripeCustomerId to create a Stripe Billing Portal session.
  • Redirects users without a Stripe customer back to /#buy.
app/api/stripe/portal/route.ts
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.completed branches between one-time and subscription checkout.
  • What capturePaymentIntent() and cancelPaymentIntent() do.
  • Where to add app-specific subscription logic.

Displaying Products & Pricing

Fetch products from Stripe and display them in your pricing section:

Fetch Products

types/StripeInterfaces.ts
import Stripe from 'stripe';

export interface IStripeProductWithPrice
  extends Omit<Stripe.Product, 'default_price'> {
  default_price: Stripe.Price & {
    unit_amount: number;
  };
}
lib/stripe/getProducts.ts
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

app/api/stripe/products/route.ts
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

components/pricing/Pricing.tsx (simplified)
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
components/pricing/Pricing.tsx (mock data logic)
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:

lib/stripe/getCoupons.ts
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:

Revalidation in webhook handler
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:

  1. Create Checkout: Click "Buy Now" on pricing page
  2. Use Test Card: 4242 4242 4242 4242
  3. Complete Checkout: Use any future date and CVC
  4. Check Webhook: Verify webhook received in Stripe Dashboard
  5. Verify Subscription: For recurring prices, check the local User subscription fields
  6. 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

On this page