This boilerplate includes a complete Stripe integration with support for both authenticated and guest checkouts, subscription management, and webhook handling.
The payment system provides:
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!# 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
Copy the price IDs from Stripe dashboard and paste them into your payments.config.ts file.
<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>
The payment system provides composables with clear separation of concerns:
usePricing() - Access Pricing ConfigLoad 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 OperationsInitiate 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 StateAccess 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.Monthly, yearly, quarterly, or weekly recurring billing:
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' }
}
}
}
]
Single charge without recurring billing:
Example config:
products: [
{
name: 'Lifetime License',
id: 'lifetime',
prices: {
usd: { id: 'lifetime-usd', stripeId: 'price_xxx' }
},
generateLicenseKey: true,
createsSubscription: true // Grants permanent subscription access
}
]
The createsSubscription option allows a one-time purchase to grant subscription access. This is perfect for lifetime licenses:
createsSubscription: true:hasAccess() checksfalse: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
}
]
Payments are tied to user accounts with automatic account creation:
Pure guest checkout without any user accounts:
Set in your pricing config:
// app/config/payments.config.ts
export const paymentsConfig = {
mode: 'authless', // Change from 'auth' to 'authless'
// ...
}
# 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"
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)
}
price_1abc123)// 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.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:
amount is configuredamount is missingamount field is for display only. Stripe will always charge the amount configured in your Stripe dashboard. Ensure they match to avoid confusion!https://yourdomain.com/api/stripe/webhookcheckout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedstripe listen --forward-to localhost:3000/api/stripe/webhook
.env file.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.
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
}
}
}
]
}
When a user with an active recurring subscription purchases a lifetime license (one-time product with createsSubscription: true):
This ensures users seamlessly transition from recurring to lifetime access without any manual intervention.
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:
XXXXX-XXXXX-XXXXX-XXXXX)createsSubscription: true, creates a lifetime subscription and cancels any existing recurring subscriptionsAccess 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' }]
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 }
})
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>
requireAnyPaidPlan: true to allow any paid plan (recommended for most cases)requiredPlans: ['pro'] when you need specific plan accessControl 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>
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:
Stripe provides test cards:
4242 4242 4242 42424000 0000 0000 00024000 0027 6000 3184Use any future expiry date and any CVC.
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
.env fileWebhookEvent table for errorsIf you see "Price not found in configuration":
payments.config.tspayments.config.ts syntaxusePricing() is called in setup