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:
- Two payment modes - Auth (with user accounts) or authless (guest checkouts)
- 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
- Multiple currencies - Dynamic pricing based on location
Payment modes
Auth mode (default)
Users must create an account before purchasing:
- Subscriptions are tied to user accounts
- Full dashboard access
- Better user experience for authenticated users
- Easier to manage customer relationships
Authless mode
Users can purchase without creating an account:
- Guest checkout with email only
- No user dashboard (unless they sign up later)
- Lower friction for one-time purchases
- Subscription management via Stripe's customer portal
NUXT_PUBLIC_PAYMENT_MODE environment variable. Set to 'auth' (default) or 'authless'.Configuration
Environment variables
# Stripe keys
STRIPE_SECRET_KEY="sk_test_..."
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# Payment mode
NUXT_PUBLIC_PAYMENT_MODE="auth" # or "authless"
# Site URLs (used in Stripe redirects)
NUXT_PUBLIC_SITE_URL="http://localhost:3000"
sk_live_ and pk_live_).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)
- Note the price IDs (e.g.,
price_1abc123)
- 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
Database architecture
The boilerplate uses two separate tables for payments and subscriptions:
Payment table
Tracks all payment transactions (one-time and subscription):
model Payment {
id String @id @default(dbgenerated("gen_random_uuid()"))
customerEmail String
userId String? // Null in authless mode
amount Int // Amount in cents
currency String
status String // succeeded, failed, pending, refunded
processorPaymentId String? // Stripe Payment Intent ID
subscriptionId String? // Link to subscription if applicable
createdAt DateTime @default(now())
}
Subscription table
Manages subscription lifecycle and access control:
model Subscription {
id String @id @default(dbgenerated("gen_random_uuid()"))
customerEmail String
userId String? // Null in authless mode
plan String // pro, elite, etc.
status String // active, canceled, past_due, etc.
processorSubscriptionId String @unique // Stripe Subscription ID
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
payments Payment[] // Payment history
createdAt DateTime @default(now())
}
Creating a checkout session
Client-side
Use the pricing page component or create your own:
<script setup lang="ts">
const loading = ref(false)
const config = useRuntimeConfig()
async function handleCheckout(priceId: string) {
loading.value = true
try {
const response = await $fetch('/api/stripe/checkout-session', {
method: 'POST',
body: { priceId },
})
// Redirect to Stripe Checkout
window.location.href = response.url
} catch (error) {
console.error('Checkout error:', error)
toast.error('Failed to create checkout session')
} finally {
loading.value = false
}
}
</script>
<template>
<Button @click="handleCheckout('price_1abc123')" :disabled="loading"> Subscribe to Pro </Button>
</template>
Server-side API
The checkout session API is in server/api/stripe/checkout-session.post.ts:
import Stripe from 'stripe'
import { getPaymentConfig } from '~/server/utils/payment-config'
export default defineEventHandler(async event => {
const { priceId } = await readBody(event)
const config = useRuntimeConfig()
const paymentConfig = getPaymentConfig()
const stripe = new Stripe(config.stripeSecretKey)
// In auth mode, get the authenticated user
let customer: { email: string; userId?: string }
if (paymentConfig.mode === 'auth') {
const { user } = await requireAuth(event)
customer = { email: user.email, userId: user.id }
} else {
// In authless mode, customer provides email at checkout
customer = { email: '' } // Stripe Checkout will collect it
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: customer.email || undefined,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${config.public.siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.public.siteUrl}/pricing`,
metadata: {
mode: paymentConfig.mode,
userId: customer.userId || '',
},
})
return { url: session.url }
})
Webhook handling
Webhooks automatically sync payment and subscription data from Stripe to your database.
Webhook endpoint
The webhook handler is in server/api/stripe/webhook.post.ts:
import Stripe from 'stripe'
export default defineEventHandler(async event => {
const config = useRuntimeConfig()
const stripe = new Stripe(config.stripeSecretKey)
const body = await readRawBody(event)
const sig = getHeader(event, 'stripe-signature')
// Verify webhook signature
const webhookEvent = stripe.webhooks.constructEvent(body, sig, config.stripeWebhookSecret)
// Handle different event types
switch (webhookEvent.type) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(webhookEvent.data.object)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdated(webhookEvent.data.object)
break
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(webhookEvent.data.object)
break
// ... other event types
}
return { received: true }
})
Webhook events explained
checkout.session.completed
- Triggered when a customer completes checkout
- Creates the initial subscription record
- In auth mode with no userId, creates a new user account
customer.subscription.created / customer.subscription.updated
- Triggered when a subscription is created or updated
- Updates subscription status, billing dates, plan info
customer.subscription.deleted
- Triggered when a subscription is canceled
- Updates subscription status to 'canceled'
invoice.payment_succeeded
- Triggered when a subscription payment succeeds
- Creates a Payment record
- Updates subscription billing dates
invoice.payment_failed
- Triggered when a subscription payment fails
- Creates a Payment record with 'failed' status
- Updates subscription status to 'past_due'
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, customerEmail } = await requireSubscription(event, {
plans: ['pro', 'elite'],
})
// User has access, proceed with logic
return {
message: 'Welcome to the premium feature!',
plan: subscription.plan,
}
})
Client-side (pages)
Protect pages with the subscription middleware:
<script setup lang="ts">
definePageMeta({
middleware: 'subscription',
requiresSubscription: true,
requiredPlans: ['pro', 'elite'],
})
</script>
<template>
<div>
<h1>Premium Feature</h1>
<p>Only available to Pro and Elite subscribers</p>
</div>
</template>
Using the subscription composable
Check subscription status in components:
<script setup lang="ts">
const { subscription, currentPlan, isSubscribed, hasAccess } = useSubscription()
// Check if user has any subscription
const showPremiumContent = computed(() => isSubscribed.value)
// Check if user has specific plan access
const canAccessFeature = computed(() => hasAccess(['pro', 'elite']))
</script>
<template>
<div v-if="showPremiumContent">
<p>Your plan: {{ currentPlan }}</p>
</div>
<div v-else>
<Button as-child>
<NuxtLink to="/pricing">Upgrade to Pro</NuxtLink>
</Button>
</div>
</template>
Customer portal
Allow customers to manage their subscriptions:
<script setup lang="ts">
const { openPortal } = useSubscription()
async function handleManageSubscription() {
await openPortal()
}
</script>
<template>
<Button @click="handleManageSubscription"> Manage subscription </Button>
</template>
The portal link is generated in server/api/stripe/portal-session.post.ts:
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${config.public.siteUrl}/settings`,
})
return { url: session.url }
Dynamic pricing by country
The boilerplate uses IPinfo to detect the user's country and show appropriate pricing:
<script setup lang="ts">
const { data: ipInfo } = await useFetch('/api/ip/info')
const currency = computed(() => {
// Show Euro for EU countries, USD otherwise
if (ipInfo.value?.isEU) {
return 'EUR'
}
return 'USD'
})
const prices = {
USD: { monthly: 29, yearly: 290 },
EUR: { monthly: 25, yearly: 250 },
}
</script>
<template>
<div>
<p>Monthly: {{ prices[currency].monthly }} {{ currency }}</p>
</div>
</template>
Testing
Test mode
Stripe provides test cards for development:
- 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 Stripe CLI
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
Common tasks
Cancel a subscription
const stripe = new Stripe(config.stripeSecretKey)
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
Reactivate a subscription
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
})
Issue a refund
await stripe.refunds.create({
payment_intent: paymentIntentId,
amount: amountInCents, // Optional, refunds full amount if omitted
})
Change subscription plan
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
})
Best practices
Always verify webhooks
// Verify signature to ensure webhook is from Stripe
const webhookEvent = stripe.webhooks.constructEvent(body, signature, webhookSecret)
Handle idempotency
The boilerplate stores webhook events to prevent duplicate processing:
// Check if already processed
const existing = await prisma.webhookEvent.findUnique({
where: { processorEventId: event.id },
})
if (existing?.processed) {
return { received: true }
}
Use metadata
Store important data in Stripe metadata:
await stripe.checkout.sessions.create({
// ...
metadata: {
userId: user.id,
mode: 'auth',
source: 'web',
},
})
Test thoroughly
- Test successful payments
- Test failed payments
- Test subscription cancellations
- Test webhook failures and retries
- Test both auth and authless modes
Troubleshooting
Webhooks not working
- Check webhook endpoint URL is correct
- Verify webhook secret matches
- Check server logs for errors
- Use Stripe CLI to test locally
Payments not creating subscriptions
- Check webhook events are being received
- Verify database has correct schema
- Check for errors in webhook handler logs
- Ensure Stripe product/price IDs are correct
Access control not working
- Verify subscription status in database
- Check
currentPeriodEndis in the future - Ensure middleware is applied to routes
- Check for authentication issues