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 Features

Prerequisites

Install the required packages:

pnpm add next-auth@beta stripe @stripe/stripe-js

Set 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 dev

Step 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

  1. Test webhook locally with Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
  1. Use test cards:

    • Success: 4242 4242 4242 4242
    • Decline: 4000 0000 0000 0002
    • Requires auth: 4000 0027 6000 3184
  2. 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

  1. Webhook reliability: Always use webhook secret verification
  2. Customer ID storage: Create customer on signup, not checkout
  3. Stale session data: Refresh subscription status on each request
  4. 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.