On this page13 sections
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.
Legacy guide: This article is preserved for teams maintaining an existing Auth.js application. The current Achromatic Pro Prisma and Pro Drizzle starter kits use Better Auth. For a new project, start with the current authentication documentation.
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 @auth/prisma-adapter 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';
let stripeClient: Stripe | null = null;
export function getStripe(): Stripe {
if (stripeClient) return stripeClient;
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('STRIPE_SECRET_KEY is not set');
}
stripeClient = new Stripe(secretKey);
return stripeClient;
}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 { getStripe } 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 stripe = getStripe();
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 { getStripe } from '@/lib/stripe';
import { absoluteUrl } from '@/lib/utils';
export async function createCheckoutSession(priceId: string) {
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
const stripe = getStripe();
// 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 stripe = getStripe();
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 { getStripe } from '@/lib/stripe';
export async function POST(request: Request) {
const body = await request.text();
const requestHeaders = await headers();
const signature = requestHeaders.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
}
const stripe = getStripe();
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;
}
const stripe = getStripe();
// 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.
Maintaining an Auth.js application? Use this article as a migration reference. For new projects, the current Pro Prisma and Pro Drizzle kits use Better Auth and include Stripe billing implementations.



