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 with automatic redirect for active subscribers
- Webhook handling - Automatic payment and subscription sync with idempotency protection
- Upgrade/downgrade support - Plan changes routed through the Stripe portal for existing subscribers
- Pending plan tracking - Scheduled downgrades are reflected in the UI before they take effect
- Configurable free trials - Disabled by default; enable with a positive
trialPeriodDaysvalue - Multiple currencies - Dynamic pricing based on location
- License key generation - Automatic license keys for one-time purchases (opt-in feature flag)
- Lifetime access - One-time products can grant permanent subscription access
- Smart subscription handling - Automatically cancels recurring subscriptions when lifetime licenses are purchased
- Static landing page pricing -
LandingPricingTablecomponent for SSR-safe public pricing pages
Quick start
1. Configure your pricing
Edit shared/config/payments.config.ts. Use the built-in createPriceConfig helper to register both your test and live Stripe price IDs together. This prevents "price not found" errors when a cached client bundle sends the other environment's ID:
export const paymentsConfig = {
mode: 'auth', // 'auth' or 'authless'
currencies: ['usd', 'eur'],
defaultCurrency: 'usd',
trialPeriodDays: 0, // 0 or omit to disable trials globally; use a positive integer (e.g. 7) to enable
features: {
licenseKeys: {
enabled: false, // Set to true to activate license key generation for one-time products
},
},
subscriptions: [
{
name: 'Pro',
id: 'pro',
description: 'For professionals',
prices: {
monthly: {
usd: createPriceConfig('price_test_xxx', 'price_live_xxx', {
id: 'pro-monthly-usd',
amount: 2900, // $29.00 (optional – display only)
}),
eur: createPriceConfig('price_test_yyy', 'price_live_yyy', {
id: 'pro-monthly-eur',
amount: 2500, // €25.00
}),
},
yearly: {
usd: createPriceConfig('price_test_zzz', 'price_live_zzz', {
id: 'pro-yearly-usd',
amount: 29000, // $290.00 (save $58)
}),
},
},
},
],
products: [
{
name: 'Lifetime License',
id: 'lifetime',
prices: {
usd: createPriceConfig('price_test_aaa', 'price_live_aaa', {
id: 'lifetime-usd',
amount: 9900, // $99.00
}),
},
createsSubscription: true, // Grants lifetime subscription access
generateLicenseKey: true, // Requires features.licenseKeys.enabled = 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 your products and prices in both your Stripe test and live dashboards:
# 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 both Stripe price IDs to config
Copy the price IDs from both your Stripe test and live dashboards and pass them to createPriceConfig:
usd: createPriceConfig(
'price_test_1abc123', // From Stripe test dashboard
'price_live_1abc123', // From Stripe live dashboard
{ id: 'pro-monthly-usd', amount: 2900 }
)
The active ID is selected at build time based on NUXT_PUBLIC_STRIPE_LIVE_MODE. See the environment variables section.
4. Use in your app
<script setup>
const { subscriptionPlans } = usePricing()
const { openCheckout } = useCheckout()
const { isTrialEligible } = storeToRefs(useUserStore())
function subscribe(plan) {
const priceConfig = plan.prices.monthly.usd
// Pass isTrialEligible as wantsTrial: eligible users get a trial,
// returning customers go straight to a paid checkout automatically.
openCheckout(priceConfig.stripeId, isTrialEligible.value)
}
</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>
openCheckout() will automatically redirect them to the Stripe Customer Portal to change their plan instead of starting a new checkout. No extra code needed.Trial periods
Trials are disabled by default. Set trialPeriodDays to a positive integer in your payments.config.ts to enable them.
Enabling trials
// shared/config/payments.config.ts
export const paymentsConfig = {
trialPeriodDays: 7, // Enable 7-day free trial for new subscriptions
// ... other config
}
trialPeriodDays to 0 (or omit the field entirely) to disable trials globally. Common values when enabled are 7, 14, or 30 days.Default behavior
const { openCheckout } = useCheckout()
// No trial by default (wantsTrial defaults to false)
await openCheckout('price_xxxxx')
Enabling trials for a specific checkout
The intended pattern is to pass isTrialEligible directly as the wantsTrial argument. New users automatically receive a trial while returning customers are sent straight to a paid checkout, with no conditional logic on your end:
const { openCheckout } = useCheckout()
const { isTrialEligible } = storeToRefs(useUserStore())
// Eligible users get a trial; ineligible users go straight to paid checkout
await openCheckout('price_xxxxx', isTrialEligible.value)
The trial will only be applied when both isTrialEligible is true and trialPeriodDays is set to a positive integer in your config. The server enforces eligibility independently, so this is safe even if the value is manipulated client-side.
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
isTrialEligibleboolean in theirUserDatarecord (defaults totrue) - 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.
Recommended usage: pass isTrialEligible to openCheckout()
const { openCheckout } = useCheckout()
const { isTrialEligible } = storeToRefs(useUserStore())
// The second argument is the wantsTrial boolean.
// Passing isTrialEligible means eligible users get a trial automatically
// and returning customers never see one. No extra if/else needed.
await openCheckout(priceConfig.stripeId, isTrialEligible.value)
You can also use it to conditionally show trial-specific UI:
// Show "Start your 7-day free trial" vs "Subscribe now" based on eligibility
if (isTrialEligible.value) {
// Display trial CTA
}
Resetting eligibility (customer support):
// Server-side only (e.g., in an admin API endpoint)
await prisma.userData.update({
where: { userId: 'user_id_here' },
data: { isTrialEligible: true }
})
Active subscriber handling
When an authenticated user who already has an active (or trialing) subscription clicks a plan, the template automatically redirects them to the Stripe Customer Portal subscription update flow rather than creating a duplicate checkout. This keeps all plan changes within the same Stripe subscription, so proration, schedules, and billing history all work correctly.
How it works end to end:
PricingPlans.vuedetects the active subscription client-side and callsopenPortal('subscription_update')directly, skipping the checkout endpoint entirely.- As a server-side safety net,
create-checkout-sessionalso checks for an active subscription and returns{ redirect: 'portal' }if one is found. openCheckout()inuseCheckoutchecks the response for this signal and opens the portal automatically.
No code changes are needed in your own components. The built-in openCheckout() composable handles both cases transparently.
Pending plan
When a subscriber schedules a plan change through the Stripe portal (e.g. a downgrade that takes effect at the end of the billing period), the webhook now detects the upcoming phase from the Stripe subscription schedule and stores it as pendingPlan on the Subscription record.
The built-in SubscriptionStatus component displays this to the user ("Plan change to X scheduled at next renewal").
Database migration
This feature requires a new database column. After pulling this branch, run:
npx prisma migrate dev
Accessing pendingPlan in your code
const { subscription } = useSubscription()
// subscription.pendingPlan is null when no change is scheduled
if (subscription.value?.pendingPlan) {
console.log(`Downgrade to "${subscription.value.pendingPlan}" scheduled`)
}
License key generation
One-time products can automatically generate license keys on purchase. This is an opt-in feature that must be enabled at the config level before any product-level generateLicenseKey flag takes effect.
Enabling the feature
// shared/config/payments.config.ts
export const paymentsConfig = {
features: {
licenseKeys: {
enabled: true, // Enable license key generation globally
},
},
// ...
}
generateLicenseKey: true is set on a product but features.licenseKeys.enabled is false (or omitted), config validation will log an error and no keys will be generated. Always enable the feature flag when using generateLicenseKey.Once enabled, add generateLicenseKey: true to any one-time product:
products: [
{
name: 'Software License',
id: 'software',
prices: {
usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'software-usd' }),
},
generateLicenseKey: true, // Requires features.licenseKeys.enabled = true
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' }]
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 (no trial by default)
await openCheckout('price_xxxxx')
// Start checkout with trial (only applies when trialPeriodDays > 0 in config)
await openCheckout('price_xxxxx', true)
// Open Stripe Customer Portal (general)
await openPortal()
// Open portal to a specific flow (e.g. plan update)
await openPortal('subscription_update')
Active subscribers who call openCheckout() are automatically redirected to the Stripe portal for plan changes.
useSubscription() - Subscription state
Access subscription information and control access:
const {
subscription, // Current subscription details (includes pendingPlan)
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', 'elite'])) {
// User has Pro OR Elite 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
trueif user has one of the specified plans AND an active subscription - Special case:
'basic'plan always returnstrue(no subscription required) - Plan names are case-insensitive
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 (disabled by default)
- Upgrade/downgrade capabilities via Stripe portal
- Pending plan tracking for scheduled changes
- Access control by plan
Example config:
subscriptions: [
{
name: 'Pro',
id: 'pro',
prices: {
monthly: {
usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'pro-monthly-usd' }),
},
yearly: {
usd: createPriceConfig('price_test_yyy', 'price_live_yyy', { id: 'pro-yearly-usd' }),
},
},
},
]
One-time payments
Single charge without recurring billing:
- Lifetime licenses
- One-off products or services
- Optional automatic license key generation (requires
features.licenseKeys.enabled) - Can optionally grant subscription access
- No recurring billing or management needed
Example config:
products: [
{
name: 'Lifetime License',
id: 'lifetime',
prices: {
usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'lifetime-usd' }),
},
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: createPriceConfig('price_test_xxx', 'price_live_xxx', {
id: 'lifetime-usd',
amount: 19900, // $199
}),
},
createsSubscription: true, // ← Grants subscription access
generateLicenseKey: true, // ← Requires features.licenseKeys.enabled = true
},
{
name: 'E-book',
id: 'ebook',
prices: {
usd: createPriceConfig('price_test_yyy', 'price_live_yyy', { id: 'ebook-usd' }),
},
// 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:
// shared/config/payments.config.ts
export const paymentsConfig = {
mode: 'authless', // Change from 'auth' to 'authless'
// ...
}
requireSubscription() on your server endpoints.Configuration
Environment variables
# Stripe keys
STRIPE_SECRET_KEY="sk_test_..."
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# Set to "true" to use live Stripe price IDs from payments.config.ts
# Must be set at build time (before `nuxt build`). Defaults to false (test mode).
NUXT_PUBLIC_STRIPE_LIVE_MODE=false
# Site URLs (used in Stripe redirects)
NUXT_PUBLIC_SITE_URL="http://localhost:3000"
NUXT_PUBLIC_STRIPE_LIVE_MODE is a build-time variable. Change it before running nuxt build for production, as it cannot be changed at runtime without rebuilding.Test vs. live price IDs
The createPriceConfig helper bundles both Stripe price IDs into each price entry. At build time, the correct ID is selected based on NUXT_PUBLIC_STRIPE_LIVE_MODE:
import { createPriceConfig } from '@@/shared/config/payments.config'
// Both IDs are stored; the active one is selected at build time
usd: createPriceConfig(
'price_test_1abc123', // Used when NUXT_PUBLIC_STRIPE_LIVE_MODE is false (default)
'price_live_1abc123', // Used when NUXT_PUBLIC_STRIPE_LIVE_MODE=true
{ id: 'pro-monthly-usd', amount: 2900 }
)
Both IDs are also registered in the server-side price lookup map via stripeLookupIds. This means the server can resolve incoming webhook price IDs from either environment without throwing a "price not found" error, which is useful when testing live webhooks against a dev build.
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[]
/** Optional feature toggles */
features?: PaymentsFeaturesConfig
currencies: string[]
defaultCurrency: string
/**
* Trial period in days. Set to 0 (or omit) to disable trials globally.
* Use a positive integer (e.g. 7, 14, 30) to enable trials.
*/
trialPeriodDays?: number
}
export interface PaymentsFeaturesConfig {
licenseKeys?: {
/** Enables license key generation and checkout success page license handling */
enabled?: boolean
}
}
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>
/** Requires features.licenseKeys.enabled = true */
generateLicenseKey?: boolean
createsSubscription?: boolean // Grants subscription access
}
export interface PriceConfig {
id: string // Internal ID
stripeId: string // Active Stripe price ID (test or live, set at build time)
amount?: number // Amount in cents (optional, for display only)
originalAmount?: number // Pre-discount amount in cents (for strikethrough display)
/**
* Both test and live Stripe price IDs. Populated automatically by createPriceConfig().
* Used by the server to resolve incoming price IDs from either environment.
*/
stripeLookupIds?: readonly [test: string, live: string]
}
/**
* Helper to create a PriceConfig with both test and live Stripe price IDs.
* The active stripeId is selected at build time via NUXT_PUBLIC_STRIPE_LIVE_MODE.
*/
declare function createPriceConfig(
testPriceId: string,
livePriceId: string,
rest: Omit<PriceConfig, 'stripeId' | 'stripeLookupIds'>
): PriceConfig
Stripe setup
- Create a Stripe account at stripe.com
- Get your API keys from the Stripe dashboard
- Create products and prices in both your test and live Stripe dashboards:
- Go to Products → Add Product
- Create a product (e.g., "Pro Plan")
- Add prices (monthly, yearly)
- Copy the price IDs from each dashboard (e.g.,
price_test_1abc123andprice_live_1abc123)
- Add both price IDs to config:
// shared/config/payments.config.ts
prices: {
monthly: {
usd: createPriceConfig(
'price_test_1abc123', // Test price ID
'price_live_1abc123', // Live price ID
{ id: 'pro-monthly-usd', amount: 2900 }
),
},
}
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.Landing page pricing table
The LandingPricingTable component is a static, SSR-safe pricing table designed for public landing pages. Unlike PricingPlans, it does not depend on authentication or subscription state, so it renders correctly during server-side rendering without hydration mismatches.
It is already wired into PricingSection.vue. To use it standalone on any page:
<template>
<LandingPricingTable />
</template>
Use PricingPlans (the interactive version) on authenticated or client-only pages where you need the active subscriber portal redirect and current plan highlighting.
Webhook plan resolution
The webhook handler uses a resolvePlanFromPrice function that cascades through multiple sources to determine the correct internal plan ID. This is important because subscription.metadata.plan is set at checkout time and is never updated when a user changes their plan through the Stripe portal.
Resolution order:
- Local config lookup (
payments.config.ts): fastest, always preferred - Stripe price metadata (
price.metadata.plan): useful when config is out of sync - Stripe product metadata (
product.metadata.plan): fallback for legacy setups - Product name matching: case-insensitive match against your configured plan IDs and names
- Normalized product name: last resort, logged as a warning
- Stale checkout metadata: only used when the Stripe API is unreachable, logged as an error
payments.config.ts via createPriceConfig. The config lookup (step 1) requires no Stripe API calls and handles both test and live IDs correctly.Common scenarios
Adding a new plan
- Create product and prices in both Stripe test and live dashboards
- Copy both price IDs
- Add to
payments.config.ts:
subscriptions: [
// ... existing plans
{
name: 'Elite',
id: 'elite',
description: 'For power users and scaling teams',
prices: {
monthly: {
usd: createPriceConfig('price_test_xxx', 'price_live_xxx', {
id: 'elite-monthly-usd',
amount: 9900,
}),
},
},
},
]
That's it. No database changes, no code changes. The new plan automatically appears on your pricing page.
Adding a new currency
- Create prices in both Stripe dashboards for the new currency
- Add to config:
export const paymentsConfig = {
currencies: ['usd', 'eur', 'gbp'],
// ...
subscriptions: [
{
// ...
prices: {
monthly: {
usd: createPriceConfig('price_test_usd', 'price_live_usd', { id: 'pro-monthly-usd' }),
eur: createPriceConfig('price_test_eur', 'price_live_eur', { id: 'pro-monthly-eur' }),
gbp: createPriceConfig('price_test_gbp', 'price_live_gbp', { id: 'pro-monthly-gbp' }), // 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
- Enable the license key feature in config:
features: {
licenseKeys: { enabled: true }
}
- Create product and price in both Stripe dashboards
- Add to config with
generateLicenseKey: true:
products: [
{
name: 'Software License',
id: 'software',
prices: {
usd: createPriceConfig('price_test_xxx', 'price_live_xxx', { id: 'software-usd' }),
},
generateLicenseKey: true, // Automatic key generation
createsSubscription: true, // Optional: grant subscription access
},
]
When a customer purchases, the system:
- Creates a payment record
- Generates a license key (format:
XXXXX-XXXXX-XXXXX-XXXXX) - Stores it in the database
- Sends the license key via email to the customer
- Displays it on the 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 elite plan
const { subscription, userId } = await requireSubscription(event, {
plans: ['pro', 'elite'],
})
// 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', 'elite'],
})
// 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
allowedPlansspecified, 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 (
isSubscribedis true)
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', 'elite']))
// 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/Elite only -->
</div>
<div v-else-if="isPaidUser">
<p>Upgrade to Pro or Elite 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>
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
- Change plans (upgrades and downgrades)
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.tsviacreatePriceConfig - If using live mode, ensure
NUXT_PUBLIC_STRIPE_LIVE_MODE=truewas set at build time - If a cached client bundle is sending the wrong environment's price ID, ensure both test and live IDs are registered.
createPriceConfighandles this automatically - 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
Plan not updating after portal plan change
The webhook now resolves the plan from the live Stripe price rather than from stale checkout metadata. If the plan still shows incorrectly after a portal change:
- Ensure the Stripe price IDs in your config match what Stripe is sending in webhook events
- Check server logs for
resolvePlanFromPricewarnings, which indicate which fallback path was used - Consider adding a
plankey to your Stripe product or price metadata as a reliable fallback
Best practices
- Version control your config - Pricing changes are tracked in git
- Use test mode - Always test with Stripe test keys first
- Register both price IDs - Use
createPriceConfigwith test and live IDs for every price entry - 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, plan changes via portal
- Keep Stripe in sync - Ensure config matches your Stripe dashboard
- Run migrations - After pulling updates, run
npx prisma migrate devto apply any schema changes
