Payments
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
}
]
}
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
}
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
}
]
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
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'
// ...
}
Configuration
Environment variables
# 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
- Create a Stripe account at stripe.com
- Get your API keys from the Stripe dashboard
- 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)
- 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)
}
}
}
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
amountis configured - Shows "Price unavailable" if
amountis missing - Calculates monthly equivalent for yearly prices
amount field is for display only. Stripe will always charge the amount configured in your Stripe dashboard. Ensure they match to avoid confusion!- Set up webhooks:
- Go to Developers → Webhooks
- Add endpoint:
https://yourdomain.com/api/stripe/webhook - Select events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
- Copy the webhook signing secret
stripe listen --forward-to localhost:3000/api/stripe/webhook
.env file.Common scenarios
Adding a new plan
- Create product and price in Stripe
- Copy the price ID
- 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
- Create prices in Stripe for the new currency
- 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):
- Automatic cancellation - All existing active recurring subscriptions are automatically canceled in Stripe
- Immediate access - User receives lifetime subscription access immediately
- No double billing - User won't be charged for the recurring subscription again
- 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
- Create product and price in Stripe
- 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:
- Creates payment record
- Generates license key (format:
XXXXX-XXXXX-XXXXX-XXXXX) - Stores in database
- Sends license key via email to customer
- Displays license key on checkout success page with copy-to-clipboard functionality
- 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: trueto 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
- Check webhook endpoint URL is correct
- Verify webhook secret matches
.envfile - Check server logs for errors
- Use Stripe CLI for local testing
- Check
WebhookEventtable for errors
Price not found error
If you see "Price not found in configuration":
- Verify the Stripe price ID is correct
- Check it's added to
payments.config.ts - Ensure the config export is correct
- Restart your development server
Plans not showing on pricing page
- Check
payments.config.tssyntax - Verify the config is being imported correctly
- Check browser console for errors
- Ensure
usePricing()is called in setup
Best practices
- Version control your config - Pricing changes are tracked in git
- Use test mode - Always test with Stripe test keys first
- Verify webhooks - The system automatically verifies webhook signatures
- Handle idempotency - Webhook events are stored to prevent duplicate processing
- Test all scenarios - Test subscriptions, one-time payments, auth and authless modes
- Keep Stripe in sync - Ensure config matches your Stripe dashboard