Pages Router Was Broken Beyond Repair
Pages Router with Stripe was hell. Build everything, test locally, deploy, and then watch payments silently fail. The client_secret
would randomly be null in production and TypeScript couldn't save you.
Spent Saturday debugging why customers couldn't pay. Dashboard looked fine. Turns out our Vercel deployment broke because some API route couldn't handle the null case. Error message? "Something went wrong." Helpful.
Here's what doesn't break when money's involved.
Core Integration Architecture
TypeScript That Actually Works
Stripe's official Node.js library finally has TypeScript definitions that don't lie. Used to have to manually type everything and guess what could be null.
// app/api/payment-intents/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-08-27', // Latest stable version - check the docs because this changes
typescript: true,
});
export async function POST(request: Request) {
const { amount, currency = 'usd' }: {
amount: number;
currency?: string;
} = await request.json();
// TypeScript knows exactly what this returns (finally)
const paymentIntent: Stripe.PaymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: { enabled: true },
});
return Response.json({
clientSecret: paymentIntent.client_secret,
});
}
Why this doesn't suck (mostly):
TypeScript finally knows client_secret
can be null. No more crashes when payments hit prod. Still crashes sometimes but at least you get a stack trace.
IDE autocomplete works for like 90% of cases. Still guess between payment_method_types
and paymentMethodTypes
when the official TypeScript definitions are wrong, but with fewer tabs open.
When Stripe breaks their API (they do every damn week), TypeScript catches it at build time. Last month some minor version update broke our checkout - think it was 14.21.0 to 14.21.1 or something stupid. Spent 4 hours tracking down that they renamed some internal interface. The Stripe Node.js changelog documents these breaking changes.
Server Components for Payment UI
Server components let you create payment intents on the server without the API route dance:
// app/checkout/page.tsx
import { Stripe } from 'stripe';
import PaymentForm from './payment-form';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-08-27',
});
interface CheckoutPageProps {
searchParams: { amount?: string };
}
export default async function CheckoutPage({ searchParams }: CheckoutPageProps) {
const amount = parseInt(searchParams.amount || '1000');
// Server-side PaymentIntent creation with type safety
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
automatic_payment_methods: { enabled: true },
});
return (
<div className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Complete Your Payment</h1>
<PaymentForm
clientSecret={paymentIntent.client_secret!}
amount={amount}
/>
</div>
);
}
What you get:
Skip the API route dance. Server components create PaymentIntents directly. Less code, fewer fuck-ups. See Vercel's Stripe integration guide for more patterns.
Users can't modify payment amounts in dev tools. Amount stays server-side where it belongs. Unlike client-side payment flows where everything's exposed.
Checkout loads immediately. No more spinner while you create payment intents. Stripe.js SDK handles the client-side rendering.
Type-Safe Environment Configuration
Validate your environment variables at startup so they don't break in production:
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
DATABASE_URL: z.string().url(),
});
export const env = envSchema.parse({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
});
// Usage provides full TypeScript support
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2025-08-27',
});
Catches missing env vars at startup, not when customers try to pay. The secret key wasn't set right on Vercel deployment and payments died silently for like 6 hours. Error message? "Something went wrong." Thanks, Stripe. Really narrowed it down there. Check Zod's environment validation patterns for more robust validation.
Webhook Hell
Webhooks show up as untyped garbage and TypeScript can't help you. Stripe's webhook types are incomplete. Type guards save your ass:
// lib/stripe-webhooks.ts
import Stripe from 'stripe';
export function isPaymentIntentSucceeded(
event: Stripe.Event
): event is Stripe.Event & { data: { object: Stripe.PaymentIntent } } {
return event.type === 'payment_intent.succeeded';
}
export function isCustomerSubscriptionUpdated(
event: Stripe.Event
): event is Stripe.Event & { data: { object: Stripe.Subscription } } {
return event.type === 'customer.subscription.updated';
}
// Usage in webhook handler
export async function POST(request: Request) {
const event = stripe.webhooks.constructEvent(/* ... */);
if (isPaymentIntentSucceeded(event)) {
// TypeScript knows event.data.object is a PaymentIntent
const paymentIntent = event.data.object;
console.log(`Payment succeeded: ${paymentIntent.id}`);
}
}
Stop Users From Breaking Your Forms
Zod prevents users from submitting $0 payments and garbage data:
const checkoutSchema = z.object({
amount: z.number().int().min(50).max(999999), // $0.50 to $9,999.99
currency: z.enum(['usd', 'eur', 'gbp']),
customer_email: z.string().email(),
});
// Validate before creating payment intent
const validatedData = checkoutSchema.parse(formData);
Users will submit negative amounts and invalid emails without this. Check Stripe's payment validation guide for more edge cases.
When Shit Breaks in Production
Catch Stripe errors or your app explodes:
try {
const paymentIntent = await stripe.paymentIntents.create(params);
return { success: true, paymentIntent };
} catch (error) {
if (error instanceof Stripe.errors.StripeError) {
// Card declined, insufficient funds, etc.
return { success: false, error: error.message };
}
// Something else broke
return { success: false, error: 'Payment failed' };
}
Common errors: card declined, insufficient funds, expired cards. Stripe's Node.js documentation helps decode the cryptic messages.
Database Sync Pain
Keep your DB schema in sync with Stripe payment statuses or payments break silently:
// Match Stripe's status enum exactly
status: z.enum(['requires_payment_method', 'requires_confirmation', 'requires_action', 'processing', 'requires_capture', 'canceled', 'succeeded']),
When Stripe updates their status values, your database inserts fail. Found this out when requires_capture
became a new status and broke our webhook handler for 3 hours. Could've sworn that status wasn't there last week, but who fucking knows. Track Stripe API changelog to catch these breaking changes.
App Router + TypeScript + Stripe beats the alternatives. Still breaks in weird ways - webhooks fail randomly and Stripe's error messages suck.
Last month customers could place orders but payments failed silently. Webhook returned 200 but didn't process anything. Stripe's webhook examples show proper retry patterns. TypeScript catches type errors but not logic bugs.