stripe-checkout
Stripe Checkout
Environment Variables
# .env.local
STRIPE_SECRET_KEY=sk_test_... # or sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
Environment Validation
// lib/env.ts
import { z } from 'zod';
const stripeEnvSchema = z.object({
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
});
export const stripeEnv = stripeEnvSchema.safeParse(process.env);
export function hasStripe(): boolean {
return stripeEnv.success;
}
Stripe Client
// lib/stripe.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
Create Checkout Session (Server Action)
// lib/actions/stripe.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { getProgrammeBySlug } from '@/lib/data';
import type { Locale } from '@/i18n.config';
interface CreateCheckoutParams {
programmeSlug: string;
locale: Locale;
}
export async function createCheckoutSession({
programmeSlug,
locale,
}: CreateCheckoutParams) {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!;
const programme = await getProgrammeBySlug(locale, programmeSlug);
if (!programme || !programme.stripePriceId) {
throw new Error('Programme not found or not available for purchase');
}
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price: programme.stripePriceId,
quantity: 1,
},
],
success_url: `${siteUrl}/${locale}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}/${locale}/checkout/cancel`,
metadata: {
programmeSlug,
locale,
},
locale: getStripeLocale(locale),
});
if (session.url) {
redirect(session.url);
}
throw new Error('Failed to create checkout session');
}
function getStripeLocale(locale: Locale): Stripe.Checkout.SessionCreateParams.Locale {
const map: Record<Locale, Stripe.Checkout.SessionCreateParams.Locale> = {
'pt-PT': 'pt',
'en': 'en',
'tr': 'tr',
'es': 'es',
'fr': 'fr',
'de': 'de',
};
return map[locale] || 'auto';
}
Buy Button Component
// components/BuyButton.tsx
'use client';
import { useTransition } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { createCheckoutSession } from '@/lib/actions/stripe';
import { Button } from '@/components/ui/button';
import type { Locale } from '@/i18n.config';
interface BuyButtonProps {
programmeSlug: string;
price: number;
disabled?: boolean;
}
export function BuyButton({ programmeSlug, price, disabled }: BuyButtonProps) {
const [isPending, startTransition] = useTransition();
const locale = useLocale() as Locale;
const t = useTranslations('checkout');
const handleBuy = () => {
startTransition(async () => {
await createCheckoutSession({ programmeSlug, locale });
});
};
return (
<Button
onClick={handleBuy}
disabled={disabled || isPending}
size="lg"
className="w-full"
>
{isPending ? t('processing') : `${t('buy')} - €${price}`}
</Button>
);
}
Webhook Handler (Critical Security)
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
// CRITICAL: Always verify webhook signature
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleSuccessfulPayment(session);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.error('Payment failed:', paymentIntent.id);
break;
}
}
return NextResponse.json({ received: true });
}
async function handleSuccessfulPayment(session: Stripe.Checkout.Session) {
const { programmeSlug, locale } = session.metadata || {};
const customerEmail = session.customer_details?.email;
// Grant access to programme
// - Save to database
// - Send confirmation email
// - Generate access credentials
console.log('Payment successful:', {
sessionId: session.id,
programmeSlug,
customerEmail,
locale,
});
}
Success Page
// app/[locale]/checkout/success/page.tsx
import { stripe } from '@/lib/stripe';
import { getTranslations } from 'next-intl/server';
import { redirect } from 'next/navigation';
type Props = {
params: Promise<{ locale: string }>;
searchParams: Promise<{ session_id?: string }>;
};
export default async function CheckoutSuccessPage({
params,
searchParams,
}: Props) {
const { locale } = await params;
const { session_id } = await searchParams;
const t = await getTranslations('checkout');
if (!session_id) {
redirect(`/${locale}`);
}
const session = await stripe.checkout.sessions.retrieve(session_id);
if (session.payment_status !== 'paid') {
redirect(`/${locale}/checkout/cancel`);
}
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-4">{t('success.title')}</h1>
<p className="text-muted-foreground mb-8">{t('success.message')}</p>
<p className="text-sm">
{t('success.emailSent', { email: session.customer_details?.email })}
</p>
</div>
);
}
Cancel Page
// app/[locale]/checkout/cancel/page.tsx
import { getTranslations } from 'next-intl/server';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
export default async function CheckoutCancelPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations('checkout');
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-4">{t('cancel.title')}</h1>
<p className="text-muted-foreground mb-8">{t('cancel.message')}</p>
<Button asChild>
<Link href={`/${locale}/programmes`}>{t('cancel.backToProgrammes')}</Link>
</Button>
</div>
);
}
Conditional Rendering (No Stripe)
// components/ProgrammeCard.tsx
import { hasStripe } from '@/lib/env';
import { BuyButton } from './BuyButton';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
export function ProgrammeCard({ programme, locale }) {
return (
<div>
{/* Programme details */}
{hasStripe() ? (
<BuyButton programmeSlug={programme.slug} price={programme.price} />
) : (
<Button asChild variant="outline">
<Link href={`/${locale}/contact`}>Contact to Purchase</Link>
</Button>
)}
</div>
);
}
Testing Webhooks Locally
# Install Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Use the webhook signing secret from CLI output
More from canatufkansu/claude-skills
next-intl-i18n
next-intl internationalization for 6 locales (pt-PT, en, tr, es, fr, de) with locale-prefixed routing, useTranslations/getTranslations patterns, and message file structure. Use when adding translations, creating localized pages, implementing language switchers, or handling locale routing.
45sitemap-robots
Automated sitemap generation for all locale URLs, robots.txt configuration, and llms.txt for AI crawler optimization. Use when setting up sitemap.xml, configuring crawling rules, or improving discoverability for search engines and AI systems.
35tailwind-shadcn
Tailwind CSS utility patterns with shadcn/ui component usage, theming via CSS variables, and responsive design. Use when styling components, installing shadcn components, implementing dark mode, or creating consistent design systems.
29json-ld-schemas
JSON-LD structured data for Organization, Person, Service, Product, FAQPage, and BreadcrumbList with reusable components. Use when implementing schema.org markup, adding rich snippets, or improving search engine understanding of page content.
22framer-motion-animations
Subtle animation patterns for hero sections, card reveals, page transitions, and scroll-triggered effects using Framer Motion. Use when adding animations to components, implementing scroll effects, or creating page transitions.
22responsive-mobile-first
Mobile-first responsive patterns with sticky headers, floating CTAs, accessible navigation, and touch-friendly interactions. Use when implementing responsive layouts, mobile navigation, or ensuring touch-friendly UI.
13