This is a demo site.Purchase now

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
  • Configurable free trials - Free trials enabled by default for all subscriptions (customizable duration)
  • 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 shared/config/payments.config.ts:

export const paymentsConfig = {
  mode: 'auth', // 'auth' or 'authless'
  currencies: ['usd', 'eur'],
  defaultCurrency: 'usd',
  trialPeriodDays: 7, // Free trial duration in days (7, 14, 30 are common)
  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>

Trial periods

By default, all subscription checkouts include a free trial. The trial duration is configurable in your payments.config.ts file (default: 7 days). During the trial period, customers can use your service without being charged. If they don't cancel before the trial ends, they'll automatically be charged and converted to a paying subscriber.

Configuring trial duration

Set the trial period duration in your payments config:

// shared/config/payments.config.ts
export const paymentsConfig = {
  trialPeriodDays: 14, // Change from default 7 days to 14 days
  // ... other config
}
The trialPeriodDays value must be a positive integer. Common values are 7, 14, or 30 days. To disable trials for specific checkout flows (like upgrades), use the wantsTrial parameter in openCheckout() rather than modifying this config value.

Default behavior

const { openCheckout } = useCheckout()

// Includes free trial by default (duration set in payments.config.ts)
await openCheckout('price_xxxxx')

Disabling trials for specific checkouts

If you need to disable the trial for a specific checkout (e.g., for upgrades or special promotions):

const { openCheckout } = useCheckout()

// Disable trial by passing false
await openCheckout('price_xxxxx', false)
Trials only apply to subscription products. One-time payments are not affected by the trial setting.

Trial eligibility system

The template includes built-in trial eligibility tracking to prevent abuse. Each user can only receive one trial per account, ensuring fair usage across your customer base.

How it works:

  • Each user has an isTrialEligible boolean in their UserData record (defaults to true)
  • When a user creates any subscription, they're automatically marked as ineligible for future trials
  • Server-side enforcement prevents ineligible users from receiving trials, even if explicitly requested via API
  • New users and unauthenticated checkouts are always eligible for trials

Business logic: Once a user becomes a paying customer (with or without using a trial), they permanently lose trial eligibility. This prevents the abuse scenario where users subscribe without trial → cancel → return later to claim a trial on the same account.

Automatic enforcement:

Your returning customers won't be offered trials automatically:

// Example: User with subscription history tries to checkout
// Server detects isTrialEligible = false and blocks the trial
await openCheckout('price_xxxxx', true) // Trial denied - user gets direct subscription

Resetting eligibility (customer support):

You can manually grant trial eligibility for customer support scenarios or special cases:

// Server-side only (e.g., in an admin API endpoint)
await prisma.userData.update({
  where: { userId: 'user_id_here' },
  data: { isTrialEligible: true }
})
Consider creating an admin UI or support tool to manage trial eligibility for your customer support team. This allows you to handle exceptions (billing errors, VIP customers, etc.) without direct database access.

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 (includes 7-day trial by default)
await openCheckout('price_xxxxx')

// Start checkout without trial
await openCheckout('price_xxxxx', false)

// 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 - accepts string or array
if (hasAccess(['pro', 'enterprise'])) {
  // User has Pro OR Enterprise plan AND active subscription
}

if (hasAccess('basic')) {
  // Always returns true - basic plan is accessible to all
}

How hasAccess() works:

  • Accepts a plan ID (string) or array of plan IDs
  • Returns true if user has one of the specified plans AND an active subscription
  • Special case: 'basic' plan always returns true (no subscription required)
  • Plan names are case-insensitive
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
  • Configurable trial periods (enabled by default)
  • 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:

// shared/config/payments.config.ts
export const paymentsConfig = {
  mode: 'authless', // Change from 'auth' to 'authless'
  // ...
}
Authless mode limitation: The subscription middleware cannot enforce access control at the client level in authless mode (no user session to check). The middleware will log a warning and allow access. Always protect authless routes with API-level subscription checks using requireSubscription() on your server endpoints.

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 (shared/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
  trialPeriodDays?: number // Trial duration in days (default: 7). Use positive integers only.
}

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:
// shared/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 specific plans
definePageMeta({
  middleware: 'subscription',
  allowedPlans: ['pro', 'enterprise'],
})

// Option 2: Require a single plan
definePageMeta({
  middleware: 'subscription',
  allowedPlans: ['pro'],
})
</script>

<template>
  <div>
    <h1>Premium Feature</h1>
    <p>Only available to subscribers with allowed plans</p>
  </div>
</template>

How it works:

  • Auth mode: Ensures user is authenticated, fetches subscription if needed, and checks if user has one of the allowed plans with an active subscription
  • Authless mode: Logs a warning and allows access at client level (API-level protection recommended)
  • If no allowedPlans specified, only authentication is enforced (auth mode only)
  • The hasAccess() check requires BOTH:
    • User's plan matches one of the allowed plans
    • User has an active subscription (isSubscribed is true)
Important: The allowedPlans check validates that the user has an active subscription AND matches one of the specified plans. In authless mode, use API-level protection with requireSubscription() for reliable access control.

Component-level

Control access within components:

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

// Check access to specific plans
const canAccessFeature = computed(() => hasAccess(['pro', 'enterprise']))

// Check if user has any active subscription
const isPaidUser = computed(() => isSubscribed.value)
</script>

<template>
  <div v-if="canAccessFeature">
    <p>Your plan: {{ currentPlan }}</p>
    <!-- Premium content for Pro/Enterprise only -->
  </div>
  <div v-else-if="isPaidUser">
    <p>Upgrade to Pro or Enterprise to access this feature</p>
    <NuxtLink to="/pricing">View plans</NuxtLink>
  </div>
  <div v-else>
    <p>Subscribe to access premium features</p>
    <NuxtLink to="/pricing">View pricing</NuxtLink>
  </div>
</template>
Use hasAccess() when you need to check for specific plans. Use isSubscribed when you just need to know if the user has any active subscription, regardless of plan.

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