Tuesday, March 25th 2025 · 2 min read
Integrating Stripe with Auth.js in Next.js: Complete Guide
Learn how to connect Stripe billing with Auth.js authentication in Next.js. Covers customer creation, subscription syncing, webhooks, and access control patterns.
Connecting authentication with billing is one of the most critical integrations in a SaaS application. Users need to sign up, subscribe to a plan, and access features based on their subscription status.
In this guide, we'll build a complete integration between Auth.js (NextAuth.js v5) and Stripe in Next.js, covering customer creation, subscription management, and feature gating.
Architecture Overview
Here's how the integration flows:
User Signup → Create Stripe Customer → Store Customer ID
↓
User Subscribes → Stripe Checkout → Webhook Updates DB
↓
User Accesses App → Check Subscription → Grant/Deny FeaturesPrerequisites
Install the required packages:
pnpm add next-auth@beta stripe @stripe/stripe-jsSet up your environment variables:
# .env.local
AUTH_SECRET=your-auth-secret
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Step 1: Database Schema
Extend your user model to store Stripe information:
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
// Stripe fields
stripeCustomerId String? @unique
stripeSubscriptionId String?
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
accounts Account[]
sessions Session[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}Run the migration:
pnpm prisma migrate devStep 2: Stripe Client Setup
Create a singleton Stripe instance:
// 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-04-10',
typescript: true
});Step 3: Create Stripe Customer on Signup
Hook into Auth.js events to create a Stripe customer when a user signs up:
// auth.ts
import { PrismaAdapter } from '@auth/prisma-adapter';
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [GitHub, Google],
events: {
createUser: async ({ user }) => {
// Create Stripe customer when user signs up
if (user.email) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name ?? undefined,
metadata: {
userId: user.id
}
});
// Save Stripe customer ID to database
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id }
});
}
}
},
callbacks: {
session: async ({ session, user }) => {
if (session.user) {
session.user.id = user.id;
// Include subscription status in session
const dbUser = await db.user.findUnique({
where: { id: user.id },
select: {
stripeSubscriptionId: true,
stripePriceId: true,
stripeCurrentPeriodEnd: true
}
});
if (dbUser) {
session.user.subscriptionId = dbUser.stripeSubscriptionId;
session.user.priceId = dbUser.stripePriceId;
session.user.subscriptionEnd = dbUser.stripeCurrentPeriodEnd;
}
}
return session;
}
}
});Extend the session types:
// types/next-auth.d.ts
import { DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {
user: {
id: string;
subscriptionId?: string | null;
priceId?: string | null;
subscriptionEnd?: Date | null;
} & DefaultSession['user'];
}
}Step 4: Checkout Session Creation
Create a server action to generate Stripe Checkout sessions:
// actions/stripe.ts
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { absoluteUrl } from '@/lib/utils';
export async function createCheckoutSession(priceId: string) {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
// Get or create Stripe customer
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true, email: true }
});
if (!user) {
throw new Error('User not found');
}
let customerId = user.stripeCustomerId;
// Create customer if doesn't exist (edge case)
if (!customerId && user.email) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: session.user.id }
});
customerId = customer.id;
await db.user.update({
where: { id: session.user.id },
data: { stripeCustomerId: customerId }
});
}
// Create checkout session
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId!,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1
}
],
success_url: absoluteUrl('/dashboard?success=true'),
cancel_url: absoluteUrl('/pricing?canceled=true'),
metadata: {
userId: session.user.id
}
});
redirect(checkoutSession.url!);
}
export async function createBillingPortalSession() {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true }
});
if (!user?.stripeCustomerId) {
throw new Error('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: absoluteUrl('/dashboard/billing')
});
redirect(portalSession.url);
}Step 5: Webhook Handler
Process Stripe webhooks to sync subscription status:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error('Webhook signature verification failed:', error);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutCompleted(session);
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionChange(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook handler error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
const subscriptionId = session.subscription as string;
if (!userId || !subscriptionId) {
console.error('Missing userId or subscriptionId');
return;
}
// Fetch full subscription details
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await db.user.update({
where: { id: userId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0]?.price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000)
}
});
}
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId }
});
if (!user) {
console.error('User not found for customer:', customerId);
return;
}
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0]?.price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000)
}
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
await db.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null
}
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// Optionally send email notification
const customerId = invoice.customer as string;
console.log('Payment failed for customer:', customerId);
}Step 6: Subscription Status Helper
Create utilities to check subscription status:
// lib/subscription.ts
import { auth } from '@/auth';
import { db } from '@/lib/db';
export type SubscriptionPlan = 'free' | 'pro' | 'enterprise';
const PLAN_PRICE_IDS: Record<string, SubscriptionPlan> = {
price_pro_monthly: 'pro',
price_pro_yearly: 'pro',
price_enterprise_monthly: 'enterprise',
price_enterprise_yearly: 'enterprise'
};
export async function getSubscription() {
const session = await auth();
if (!session?.user?.id) {
return { plan: 'free' as SubscriptionPlan, isActive: false };
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: {
stripePriceId: true,
stripeCurrentPeriodEnd: true
}
});
if (!user?.stripePriceId || !user?.stripeCurrentPeriodEnd) {
return { plan: 'free' as SubscriptionPlan, isActive: false };
}
const isActive = user.stripeCurrentPeriodEnd > new Date();
const plan = PLAN_PRICE_IDS[user.stripePriceId] || 'free';
return { plan, isActive };
}
export async function requireSubscription(minimumPlan: SubscriptionPlan) {
const { plan, isActive } = await getSubscription();
const planHierarchy: SubscriptionPlan[] = ['free', 'pro', 'enterprise'];
const currentIndex = planHierarchy.indexOf(plan);
const requiredIndex = planHierarchy.indexOf(minimumPlan);
if (!isActive || currentIndex < requiredIndex) {
throw new Error('Subscription required');
}
return { plan, isActive };
}Step 7: Feature Gating Components
Create components to gate features based on subscription:
// components/subscription-gate.tsx
import { getSubscription, SubscriptionPlan } from '@/lib/subscription';
import { UpgradePrompt } from './upgrade-prompt';
type Props = {
requiredPlan: SubscriptionPlan;
children: React.ReactNode;
fallback?: React.ReactNode;
};
export async function SubscriptionGate({
requiredPlan,
children,
fallback
}: Props) {
const { plan, isActive } = await getSubscription();
const planHierarchy: SubscriptionPlan[] = ['free', 'pro', 'enterprise'];
const hasAccess =
isActive &&
planHierarchy.indexOf(plan) >= planHierarchy.indexOf(requiredPlan);
if (!hasAccess) {
return fallback ?? <UpgradePrompt requiredPlan={requiredPlan} />;
}
return <>{children}</>;
}Usage in pages:
// app/dashboard/analytics/page.tsx
import { SubscriptionGate } from '@/components/subscription-gate';
import { AnalyticsDashboard } from './analytics-dashboard';
export default function AnalyticsPage() {
return (
<SubscriptionGate requiredPlan="pro">
<AnalyticsDashboard />
</SubscriptionGate>
);
}Step 8: Pricing Page with Checkout
Build a pricing page that triggers checkout:
// app/pricing/page.tsx
import { createCheckoutSession } from '@/actions/stripe';
import { auth } from '@/auth';
import { Button } from '@/components/ui/button';
import { getSubscription } from '@/lib/subscription';
const plans = [
{
name: 'Pro',
priceId: 'price_pro_monthly',
price: '$29',
features: ['Feature 1', 'Feature 2', 'Feature 3']
},
{
name: 'Enterprise',
priceId: 'price_enterprise_monthly',
price: '$99',
features: ['All Pro features', 'Feature 4', 'Feature 5']
}
];
export default async function PricingPage() {
const session = await auth();
const { plan: currentPlan } = await getSubscription();
return (
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto p-8">
{plans.map((plan) => (
<div
key={plan.priceId}
className="border rounded-lg p-6"
>
<h3 className="text-2xl font-bold">{plan.name}</h3>
<p className="text-4xl font-bold mt-4">
{plan.price}
<span className="text-sm font-normal">/month</span>
</p>
<ul className="mt-6 space-y-2">
{plan.features.map((feature) => (
<li key={feature}>✓ {feature}</li>
))}
</ul>
<form
action={async () => {
'use server';
await createCheckoutSession(plan.priceId);
}}
className="mt-6"
>
<Button
type="submit"
className="w-full"
disabled={currentPlan === plan.name.toLowerCase() || !session}
>
{!session
? 'Sign in to subscribe'
: currentPlan === plan.name.toLowerCase()
? 'Current Plan'
: 'Subscribe'}
</Button>
</form>
</div>
))}
</div>
);
}Testing the Integration
- Test webhook locally with Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe-
Use test cards:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - Requires auth:
4000 0027 6000 3184
- Success:
-
Verify data flow:
- Sign up → Check Stripe Dashboard for new customer
- Subscribe → Check database for subscription fields
- Cancel → Verify webhook clears subscription data
Common Pitfalls
- Webhook reliability: Always use webhook secret verification
- Customer ID storage: Create customer on signup, not checkout
- Stale session data: Refresh subscription status on each request
- Missing metadata: Always pass userId in checkout metadata
Conclusion
Integrating Auth.js with Stripe requires coordinating multiple systems:
- User creation triggers Stripe customer creation
- Checkout sessions link to authenticated users
- Webhooks sync subscription status to your database
- Session callbacks expose subscription data to the frontend
With this foundation, you can build sophisticated billing features like team subscriptions, usage-based billing, and subscription upgrades.
Need authentication and billing without the setup? Achromatic comes with Auth.js and Stripe pre-integrated—subscriptions, webhooks, and customer portal working out of the box.
Related Articles
How to Implement Metered Billing with Stripe in Next.js
Learn how to implement usage-based metered billing with Stripe in your Next.js SaaS. Covers metered subscriptions, usage reporting, and real-time tracking.
New Achromatic Starter Kits Are Here
Next.js 16, React 19, Better Auth, tRPC with Prisma or Drizzle ORM. Ship your SaaS faster than ever.
React DoS & Source Code Exposure - Starter Kits Updated
Two new React Server Components vulnerabilities discovered. All Achromatic starter kits updated to patched versions.