Trying to build a SaaS without proper TypeScript setup? Yeah, that's how you end up refunding someone $2,147 when their plan was supposed to be $21/month. Turns out someone passed cents as an integer instead of the price ID string. TypeScript would've caught that stupid type conversion bug immediately, but we were too clever for types back then.
Anyway, here's the thing about TypeScript in this stack - it helps you model the relationships between all these services without your data getting fucked up between API calls.
Why TypeScript Matters for Payment Integration
Payment processing will ruin your life if you get it wrong. One typo and you're either losing money or overcharging customers. Stripe's TypeScript SDK is decent, but it won't save you from yourself. The Stripe API reference has types, but they're not foolproof. I've seen payment processing best practices guides that completely ignore the edge cases that break at 2am.
Consider this common mistake:
// ❌ This will silently fail and you'll spend 2 hours figuring out
// that you passed "price_free" instead of a real price ID
const createSubscription = async (customerId: string, price: string) => {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price }],
});
// Ask me how I know this breaks in production
};
Here's what actually works when you need to not bankrupt yourself:
// ✅ Type-safe with proper validation
import { z } from 'zod';
const CreateSubscriptionSchema = z.object({
customerId: z.string().min(1),
priceId: z.string().startsWith('price_'),
});
type CreateSubscriptionInput = z.infer<typeof CreateSubscriptionSchema>;
const createSubscription = async (input: CreateSubscriptionInput) => {
const { customerId, priceId } = CreateSubscriptionSchema.parse(input);
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
});
return subscription;
};
Database Type Generation Strategy
Supabase generates TypeScript types from your database schema, which is great until you change a column name and your entire frontend explodes. Your types ARE the contract - mess with the database without regenerating types and you're debugging "Property 'user_id' does not exist" errors on Sunday morning. The Supabase CLI documentation shows local development setup, but the Supabase TypeScript guide glosses over how this breaks in real projects.
Generate types regularly during development:
npx supabase gen types typescript --project-id your-project-id > types/database.types.ts
When this fails (and it fucking will), you'll get some bullshit error like "socket hang up" or "connection refused."
Just run it again. The Supabase CLI is temperamental as hell - sometimes it works, sometimes it doesn't, no one knows why.
Critical pattern - separate generated types from application types:
// types/database.types.ts (generated)
export interface Database {
public: {
Tables: {
users: {
Row: {
id: string;
email: string;
created_at: string;
};
Insert: {
id?: string;
email: string;
created_at?: string;
};
Update: {
id?: string;
email?: string;
created_at?: string;
};
};
};
};
}
// types/application.types.ts (your business logic types)
import { Database } from './database.types';
export type UserRow = Database['public']['Tables']['users']['Row'];
export type CreateUserInput = Database['public']['Tables']['users']['Insert'];
export interface UserProfile extends UserRow {
subscription_status: 'active' | 'canceled' | 'past_due';
stripe_customer_id: string;
}
Next.js 15 App Router Integration Patterns
Next.js 15 made Request APIs async, breaking every cookies()
and headers()
call you've ever written. Now you need await
everywhere or your build fails with "cookies is not a function" errors.
The migration broke every auth check I'd written because apparently backwards compatibility is for chumps. Spent a solid day just adding await
keywords everywhere like some kind of TypeScript zombie.
The Next.js TypeScript documentation tries to help, but good luck migrating a real app. The App Router documentation assumes you're starting fresh. For auth, the Supabase Next.js Auth guide and middleware docs will get you started, then you'll spend a week fixing edge cases.
Server Components with proper TypeScript:
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const supabase = await createClient();
const {
data: { user },
error
} = await supabase.auth.getUser();
if (!user || error) {
redirect('/auth/login');
}
// TypeScript knows user exists here
return <DashboardContent user={user} />;
}
interface DashboardContentProps {
user: User; // Import from @supabase/supabase-js
}
function DashboardContent({ user }: DashboardContentProps) {
// Fully typed component
return <div>Welcome, {user.email}</div>;
}
Server Actions with validation:
// app/actions/subscription.ts
'use server';
import { z } from 'zod';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
const UpdateSubscriptionSchema = z.object({
priceId: z.string().startsWith('price_'),
customerId: z.string().startsWith('cus_'),
});
export async function updateSubscription(
prevState: any,
formData: FormData
) {
const supabase = await createClient();
const rawData = {
priceId: formData.get('priceId'),
customerId: formData.get('customerId'),
};
const result = UpdateSubscriptionSchema.safeParse(rawData);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
try {
// Type-safe database and Stripe operations
const { data: user } = await supabase.auth.getUser();
if (!user.user) {
redirect('/auth/login');
}
const subscription = await stripe.subscriptions.create({
customer: result.data.customerId,
items: [{ price: result.data.priceId }],
});
// Update database with new subscription
const { error } = await supabase
.from('user_subscriptions')
.upsert({
user_id: user.user.id,
stripe_subscription_id: subscription.id,
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000),
});
if (error) {
throw error;
}
return { success: true };
} catch (error) {
return {
errors: {
_form: ['Failed to update subscription'],
},
};
}
}
Payment Processing Type Safety
Stripe webhooks are a TypeScript nightmare. Everything comes in as any
and you're back to JavaScript-style debugging.
Found this out the hard way when Stripe API version 2023-10-16 changed their subscription object structure - suddenly our webhook handler started throwing "Cannot read property 'current_period_end' of undefined" because they moved it into a nested object. Three hours of debugging later, I realized our validation was based on the old payload format.
Their documentation shows these perfect examples but conveniently skips the part where webhooks arrive out of order, duplicate, or just randomly missing fields. Zod documentation helps validate webhook event types, but you'll still miss edge cases.
Type-safe webhook processing:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { z } from 'zod';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Define webhook event schemas
const CustomerSubscriptionUpdatedSchema = z.object({
id: z.string(),
object: z.literal('subscription'),
customer: z.string(),
status: z.enum(['active', 'canceled', 'incomplete', 'past_due', 'unpaid']),
current_period_end: z.number(),
current_period_start: z.number(),
items: z.object({
data: z.array(z.object({
price: z.object({
id: z.string(),
unit_amount: z.number(),
}),
})),
}),
});
type SubscriptionUpdated = z.infer<typeof CustomerSubscriptionUpdatedSchema>;
export async function POST(request: NextRequest) {
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 });
}
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 400 }
);
}
}
async function handleSubscriptionUpdated(subscription: any) {
// Validate the webhook data
const result = CustomerSubscriptionUpdatedSchema.safeParse(subscription);
if (!result.success) {
console.error('Invalid subscription data:', result.error);
return;
}
const validatedSubscription = result.data;
// Type-safe database operations
const supabase = createServiceRoleClient();
const { error } = await supabase
.from('user_subscriptions')
.update({
status: validatedSubscription.status,
current_period_end: new Date(validatedSubscription.current_period_end * 1000),
updated_at: new Date().toISOString(),
})
.eq('stripe_subscription_id', validatedSubscription.id);
if (error) {
console.error('Database update error:', error);
throw error;
}
}
This catches Stripe's bullshit API changes before they cost you money. Way better than finding out from angry customer emails.
Next up: authentication patterns that won't leave you debugging "user is null" errors in production.