Payments

Accept payments and manage subscriptions with Stripe

This boilerplate includes a complete Stripe integration with support for both authenticated and guest checkouts, subscription management, and webhook handling.

Overview

The payment system provides:

  • Config-driven pricing - Define all plans and products in a single TypeScript file
  • Two payment types - One-time purchases and recurring subscriptions
  • Two payment modes - Auth (with user accounts) or authless (guest checkouts)
  • Automatic account creation - In auth mode, guest checkouts automatically create user accounts
  • Stripe Checkout - Secure, hosted payment pages
  • Subscription management - Recurring billing and cancellations
  • Customer portal - Self-service subscription management
  • Webhook handling - Automatic payment and subscription sync with idempotency protection
  • Upgrade/downgrade support - Automatic handling of plan changes
  • Trial periods - Full support for free trials
  • Multiple currencies - Dynamic pricing based on location
  • License key generation - Automatic license keys for one-time purchases
  • Lifetime access - One-time products can grant permanent subscription access
  • Smart subscription handling - Automatically cancels recurring subscriptions when lifetime licenses are purchased

Quick start

1. Configure your pricing

Edit app/config/payments.config.ts:

export const paymentsConfig = {
  mode: 'auth', // 'auth' or 'authless'
  currencies: ['usd', 'eur'],
  defaultCurrency: 'usd',
  subscriptions: [
    {
      name: 'Pro',
      id: 'pro',
      description: 'For professionals',
      prices: {
        monthly: {
          usd: { 
            id: 'pro-monthly-usd', 
            stripeId: 'price_xxx',
            amount: 2900 // $29.00 (optional - for display)
          },
          eur: { 
            id: 'pro-monthly-eur', 
            stripeId: 'price_yyy',
            amount: 2500 // €25.00
          }
        },
        yearly: {
          usd: { 
            id: 'pro-yearly-usd', 
            stripeId: 'price_zzz',
            amount: 29000 // $290.00 (save $58)
          }
        }
      }
    }
  ],
  products: [
    {
      name: 'Lifetime License',
      id: 'lifetime',
      prices: {
        usd: { 
          id: 'lifetime-usd', 
          stripeId: 'price_aaa',
          amount: 9900 // $99.00
        }
      },
      createsSubscription: true, // Grants lifetime subscription access
      generateLicenseKey: true
    }
  ]
}
The amount field is optional and for display purposes only. Stripe will charge the amount configured in your Stripe dashboard, regardless of what's in this config. Always ensure they match!

2. Create products in Stripe

# Create product
stripe products create --name="Pro" --description="Professional plan"

# Create prices
stripe prices create --product=prod_xxx --unit-amount=2900 --currency=usd --recurring[interval]=month

3. Add Stripe price IDs to config

Copy the price IDs from Stripe dashboard and paste them into your payments.config.ts file.

4. Use in your app

<script setup>
const { subscriptionPlans } = usePricing()
const { openCheckout } = useCheckout()

function subscribe(plan) {
  const priceConfig = plan.prices.monthly.usd
  openCheckout(priceConfig.stripeId)
}
</script>

<template>
  <div v-for="plan in subscriptionPlans" :key="plan.id">
    <h3>{{ plan.name }}</h3>
    <ul><li v-for="f in plan.features">{{ f }}</li></ul>
    <button @click="subscribe(plan)">Subscribe</button>
  </div>
</template>

Composable API

The payment system provides composables with clear separation of concerns:

usePricing() - Access Pricing Config

Load subscription plans and one-time products from config:

const {
  subscriptionPlans,      // All subscription plans
  oneTimeProducts,        // All one-time products
  currency,               // Current selected currency
  availableCurrencies,    // All supported currencies
  getSubscriptionPriceId, // Get Stripe price ID
  findPlan,               // Find plan by id
} = usePricing()

useCheckout() - Payment Operations

Initiate payments and manage billing:

const { openCheckout, openPortal } = useCheckout()

// Start checkout with Stripe price ID
await openCheckout('price_xxxxx')

// Open Stripe Customer Portal
await openPortal()

useSubscription() - Subscription State

Access subscription information and control access:

const {
  subscription,       // Current subscription details
  currentPlan,        // Plan id (e.g., 'pro')
  subscriptionStatus, // Status (active, trialing, etc.)
  isSubscribed,       // Boolean
  hasAccess,          // Check access to plans
  fetchSubscription,  // Refresh data
} = useSubscription()

// Control feature access
if (hasAccess(['pro', 'enterprise'])) {
  // Show premium feature
}
For one-time payments, you only need usePricing() and useCheckout(). For subscriptions with access control, use all three composables together.

Payment Types

Recurring Subscriptions

Monthly, yearly, quarterly, or weekly recurring billing:

  • Automatic recurring charges
  • Subscription management via customer portal
  • Trial period support
  • Upgrade/downgrade capabilities
  • Access control by plan

Example config:

subscriptions: [
  {
    name: 'Pro',
    id: 'pro',
    prices: {
      monthly: {
        usd: { id: 'pro-monthly-usd', stripeId: 'price_xxx' }
      },
      yearly: {
        usd: { id: 'pro-yearly-usd', stripeId: 'price_yyy' }
      }
    }
  }
]

One-Time Payments

Single charge without recurring billing:

  • Lifetime licenses
  • One-off products or services
  • Optional automatic license key generation
  • Can optionally grant subscription access
  • No recurring billing or management needed

Example config:

products: [
  {
    name: 'Lifetime License',
    id: 'lifetime',
    prices: {
      usd: { id: 'lifetime-usd', stripeId: 'price_xxx' }
    },
    generateLicenseKey: true,
    createsSubscription: true // Grants permanent subscription access
  }
]

Creating subscriptions from one-time products

The createsSubscription option allows a one-time purchase to grant subscription access. This is perfect for lifetime licenses:

  • When createsSubscription: true:
    • Creates a subscription record with 100-year expiration
    • Automatically cancels any existing recurring subscriptions
    • User gains full subscription access through hasAccess() checks
    • Works with all access control features (middleware, API protection, etc.)
  • When omitted or false:
    • Only creates a payment record
    • No subscription access granted
    • Suitable for one-off purchases that don't need feature access

Use case example:

products: [
  {
    name: 'Lifetime Pro License',
    id: 'lifetime',
    description: 'One-time payment, lifetime access',
    prices: {
      usd: { 
        id: 'lifetime-usd', 
        stripeId: 'price_xxx',
        amount: 19900 // $199
      }
    },
    createsSubscription: true, // ← Grants subscription access
    generateLicenseKey: true
  },
  {
    name: 'E-book',
    id: 'ebook',
    prices: {
      usd: { id: 'ebook-usd', stripeId: 'price_yyy' }
    },
    // No createsSubscription - just a simple one-time purchase
  }
]
The system automatically detects whether a price is for a subscription or one-time payment based on the config structure. No manual configuration needed!

Payment Modes

Auth mode (default)

Payments are tied to user accounts with automatic account creation:

  • Authenticated users: Use their existing account
  • Guest users: Account is automatically created after payment
  • Full dashboard access for all customers
  • Email verification sent automatically
  • Better user experience with unified authentication
In auth mode, users don't need to manually create an account. They can go straight to checkout, and an account will be automatically created when their payment succeeds.

Authless mode

Pure guest checkout without any user accounts:

  • Checkout with email only
  • No user dashboard access
  • Lower friction for simple payment flows
  • Subscription management via Stripe's customer portal only
  • Works with both recurring subscriptions and one-time payments
  • Suitable for simple monetization without user management complexity

Set in your pricing config:

// app/config/payments.config.ts
export const paymentsConfig = {
  mode: 'authless', // Change from 'auth' to 'authless'
  // ...
}
When using authless mode with client-side subscription middleware, access control happens primarily at the API level. The middleware will log a warning for routes that require subscriptions in authless mode.

Configuration

Environment variables

.env
# Stripe keys
STRIPE_SECRET_KEY="sk_test_..."
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."

# Site URLs (used in Stripe redirects)
NUXT_PUBLIC_SITE_URL="http://localhost:3000"

Payments config structure

The payments config file (app/config/payments.config.ts) defines all your plans and products:

export interface PaymentsConfig {
  mode: 'auth' | 'authless' // Payment mode
  subscriptions?: SubscriptionPlan[]
  products?: OneTimeProduct[]
  currencies: string[]
  defaultCurrency: string
}

export interface SubscriptionPlan {
  name: string           // Display name
  id: string             // Internal identifier
  description?: string
  prices: {
    monthly?: Record<string, PriceConfig>
    yearly?: Record<string, PriceConfig>
    quarterly?: Record<string, PriceConfig>
    weekly?: Record<string, PriceConfig>
  }
}

export interface OneTimeProduct {
  name: string
  id: string
  description?: string
  prices: Record<string, PriceConfig>
  generateLicenseKey?: boolean
  createsSubscription?: boolean // Grants subscription access
}

export interface PriceConfig {
  id: string       // Internal ID
  stripeId: string // Stripe price ID
  amount?: number  // Amount in cents (optional, for display only)
}

Stripe setup

  1. Create a Stripe account at stripe.com
  2. Get your API keys from the Stripe dashboard
  3. Create products and prices in Stripe:
    • Go to Products → Add Product
    • Create a product (e.g., "Pro Plan")
    • Add prices (monthly, yearly)
    • Copy the price IDs (e.g., price_1abc123)
  4. Add price IDs to config:
// app/config/payments.config.ts
prices: {
  monthly: {
    usd: {
      id: 'pro-monthly-usd',
      stripeId: 'price_1abc123...', // Paste from Stripe
      amount: 2900 // $29.00 (optional - for display)
    }
  }
}
The amount field is optional. Add it if you want to display prices on your pricing page. If omitted, you can show a generic "Get started" button instead.

Displaying prices

If you add the optional amount field to your price configs, the pricing page will automatically display them:

<script setup>
const { subscriptionPlans } = usePricing()

function formatPrice(plan, interval, currency) {
  const priceConfig = plan.prices[interval]?.[currency]
  
  if (!priceConfig?.amount) {
    return 'Contact us' // No amount configured
  }
  
  return `$${(priceConfig.amount / 100).toFixed(2)}` // $29.00
}
</script>

The built-in PricingPlans component handles this automatically:

  • Shows price if amount is configured
  • Shows "Price unavailable" if amount is missing
  • Calculates monthly equivalent for yearly prices
Important: The amount field is for display only. Stripe will always charge the amount configured in your Stripe dashboard. Ensure they match to avoid confusion!
  1. Set up webhooks:
    • Go to Developers → Webhooks
    • Add endpoint: https://yourdomain.com/api/stripe/webhook
    • Select events:
      • checkout.session.completed
      • customer.subscription.created
      • customer.subscription.updated
      • customer.subscription.deleted
      • invoice.payment_succeeded
      • invoice.payment_failed
    • Copy the webhook signing secret
For local development, use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/stripe/webhook
This outputs a webhook signing secret for your .env file.

Common scenarios

Adding a new plan

  1. Create product and price in Stripe
  2. Copy the price ID
  3. Add to payments.config.ts:
subscriptions: [
  // ... existing plans
  {
    name: 'Enterprise',
    id: 'enterprise',
    description: 'For large teams',
    prices: {
      monthly: {
        usd: { id: 'ent-monthly-usd', stripeId: 'price_xxx' }
      }
    }
  }
]

That's it! No database changes, no code changes. The new plan automatically appears in your pricing page.

Adding a new currency

  1. Create prices in Stripe for the new currency
  2. Add to config:
export const paymentsConfig = {
  currencies: ['usd', 'eur', 'gbp'],
  // ...
  subscriptions: [
    {
      // ...
      prices: {
        monthly: {
          usd: { id: 'pro-monthly-usd', stripeId: 'price_xxx' },
          eur: { id: 'pro-monthly-eur', stripeId: 'price_yyy' },
          gbp: { id: 'pro-monthly-gbp', stripeId: 'price_zzz' } // New currency
        }
      }
    }
  ]
}

Handling lifetime purchases with active subscriptions

When a user with an active recurring subscription purchases a lifetime license (one-time product with createsSubscription: true):

  1. Automatic cancellation - All existing active recurring subscriptions are automatically canceled in Stripe
  2. Immediate access - User receives lifetime subscription access immediately
  3. No double billing - User won't be charged for the recurring subscription again
  4. Database sync - Old subscription records are marked as canceled in the database

This ensures users seamlessly transition from recurring to lifetime access without any manual intervention.

One-time payment with license key

  1. Create product and price in Stripe
  2. Add to config with generateLicenseKey: true:
products: [
  {
    name: 'Software License',
    id: 'software',
    prices: {
      usd: { id: 'software-usd', stripeId: 'price_xxx' }
    },
    generateLicenseKey: true, // Automatic key generation
    createsSubscription: true // Optional: grant subscription access
  }
]

When a customer purchases, the system:

  1. Creates payment record
  2. Generates license key (format: XXXXX-XXXXX-XXXXX-XXXXX)
  3. Stores in database
  4. Sends license key via email to customer
  5. Displays license key on checkout success page with copy-to-clipboard functionality
  6. If createsSubscription: true, creates a lifetime subscription and cancels any existing recurring subscriptions

Access keys in your code:

const payment = await prisma.payment.findUnique({
  where: { id: paymentId },
  include: { licenseKeys: true }
})

console.log(payment.licenseKeys)
// [{ key: 'ABC12-DEF34-GHI56-JKL78', status: 'active' }]

Access control

Server-side (API routes)

Protect API endpoints with subscription requirements:

import { requireSubscription } from '@@/server/utils/require-subscription'

export default defineEventHandler(async event => {
  // Require active subscription to pro or enterprise plan
  const { subscription, userId } = await requireSubscription(event, {
    plans: ['pro', 'enterprise'],
  })

  // User has access, proceed with logic
  return { message: 'Welcome!', plan: subscription.plan }
})

Client-side (pages)

Protect pages with the subscription middleware:

<script setup>
// Option 1: Require any paid plan
definePageMeta({
  middleware: 'subscription',
  requireAnyPaidPlan: true,
})

// Option 2: Require specific plans
definePageMeta({
  middleware: 'subscription',
  requiredPlans: ['pro', 'enterprise'],
})

// Option 3: Just fetch subscription without access control
definePageMeta({
  middleware: 'subscription',
})
</script>

<template>
  <div>
    <h1>Premium Feature</h1>
    <p>Only available to paid subscribers</p>
  </div>
</template>
  • Use requireAnyPaidPlan: true to allow any paid plan (recommended for most cases)
  • Use requiredPlans: ['pro'] when you need specific plan access
  • Use middleware alone to fetch subscription data without enforcing access control

Component-level

Control access within components:

<script setup>
const { hasAccess, currentPlan } = useSubscription()

const canAccessFeature = computed(() => hasAccess(['pro', 'enterprise']))
</script>

<template>
  <div v-if="canAccessFeature">
    <p>Your plan: {{ currentPlan }}</p>
    <!-- Premium content -->
  </div>
  <div v-else>
    <NuxtLink to="/pricing">Upgrade to access this feature</NuxtLink>
  </div>
</template>

Customer portal

Allow customers to manage their subscriptions:

<script setup>
const { openPortal } = useCheckout()
</script>

<template>
  <button @click="openPortal">Manage subscription</button>
</template>

The portal allows customers to:

  • Update payment methods
  • View payment history
  • Cancel or reactivate subscriptions
  • Download invoices

Testing

Test mode

Stripe provides test cards:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • 3D Secure: 4000 0027 6000 3184

Use any future expiry date and any CVC.

Testing webhooks locally

Use the Stripe CLI:

# Install
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_succeeded

Troubleshooting

Webhooks not working

  1. Check webhook endpoint URL is correct
  2. Verify webhook secret matches .env file
  3. Check server logs for errors
  4. Use Stripe CLI for local testing
  5. Check WebhookEvent table for errors

Price not found error

If you see "Price not found in configuration":

  1. Verify the Stripe price ID is correct
  2. Check it's added to payments.config.ts
  3. Ensure the config export is correct
  4. Restart your development server

Plans not showing on pricing page

  1. Check payments.config.ts syntax
  2. Verify the config is being imported correctly
  3. Check browser console for errors
  4. Ensure usePricing() is called in setup

Best practices

  1. Version control your config - Pricing changes are tracked in git
  2. Use test mode - Always test with Stripe test keys first
  3. Verify webhooks - The system automatically verifies webhook signatures
  4. Handle idempotency - Webhook events are stored to prevent duplicate processing
  5. Test all scenarios - Test subscriptions, one-time payments, auth and authless modes
  6. Keep Stripe in sync - Ensure config matches your Stripe dashboard

Reference