Add Stripe Payments to Vue 3: Setup Guide (2026)
Add Stripe payments to your Vue 3 app with the Payment Element, payment intents, and webhooks. Working Composition API code examples.
You built a Vue 3 app, people want to pay for it, and now you need to add Stripe payments to Vue. Stripe’s official docs lean hard on React examples, so Vue developers end up translating JSX patterns into the Composition API by hand. This guide gives you the Vue version directly — the Payment Element, the backend payment intent, and webhook handling — with code that runs.
Stripe doesn’t ship an official Vue SDK, but it doesn’t need to. You load @stripe/stripe-js directly and mount Stripe Elements into your Vue components. The community vue-stripe-js wrapper exists if you want thin Vue components around the Elements, but you can do the whole thing with the official library and a couple of refs.
What You Need First
- A Stripe account and your publishable and secret keys (test mode to start)
- A Vue 3 app (Vite is the standard scaffold)
- A backend that can hold your secret key — a Node server, a serverless function, or any HTTP endpoint
Your secret key never touches the browser. Anything that creates a payment intent or verifies a webhook runs server-side.
Step 1: Install the Stripe Library
npm install @stripe/stripe-js
That single package loads Stripe.js, which renders the Payment Element and handles card tokenization. On your backend you’ll also want the Node SDK:
npm install stripe
Step 2: Create a Payment Intent on the Backend
A Payment Intent tracks a single payment from creation through confirmation. The backend creates it with the amount, and returns the client_secret your Vue app needs to confirm it. Critically, the amount is set on the server — never trust an amount sent from the browser.
// server.js (Express)
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.use(express.json());
app.post('/api/create-payment-intent', async (req, res) => {
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: 1999, // $19.99 in cents — calculated server-side
currency: 'usd',
automatic_payment_methods: { enabled: true },
});
res.json({ clientSecret: paymentIntent.client_secret });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(4242);
Step 3: Build the Checkout Component
This is where the Vue-specific work happens. You load Stripe, create an Elements instance with the client secret, mount the Payment Element into a DOM node, and confirm the payment on submit. The onMounted and onBeforeUnmount lifecycle hooks handle setup and teardown.
<!-- CheckoutForm.vue -->
<script setup>
import { ref, onMounted } from 'vue';
import { loadStripe } from '@stripe/stripe-js';
const paymentElementRef = ref(null);
const message = ref('');
const isProcessing = ref(false);
let stripe = null;
let elements = null;
onMounted(async () => {
stripe = await loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
const res = await fetch('/api/create-payment-intent', { method: 'POST' });
const { clientSecret } = await res.json();
elements = stripe.elements({ clientSecret });
const paymentElement = elements.create('payment');
paymentElement.mount(paymentElementRef.value);
});
async function handleSubmit() {
if (!stripe || !elements) return;
isProcessing.value = true;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment-success`,
},
});
if (error) {
message.value = error.message ?? 'Something went wrong.';
}
isProcessing.value = false;
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div ref="paymentElementRef"></div>
<button :disabled="isProcessing">
{{ isProcessing ? 'Processing…' : 'Pay now' }}
</button>
<p v-if="message">{{ message }}</p>
</form>
</template>
A few things to notice. loadStripe and the elements instance are plain variables, not reactive refs — they’re SDK objects, and wrapping them in ref() would needlessly proxy them through Vue’s reactivity system. Only the UI state (message, isProcessing) needs to be reactive. The Payment Element mounts into a ref-bound <div> once we have a client secret.
The Payment Element itself renders 25+ payment methods — cards, Apple Pay, Google Pay, Link — and automatically shows the most relevant ones based on the customer’s location and your dashboard settings. You don’t build card-number inputs by hand; Stripe handles PCI compliance inside its own iframe.
Step 4: Handle Webhooks
After confirmPayment, Stripe redirects the user to your return_url, but the authoritative confirmation comes through a webhook. The redirect can be interrupted — a closed tab, a flaky network — so you grant access based on the webhook, not the redirect.
// server.js — webhook handler
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'payment_intent.succeeded':
// Grant access, send a receipt, update your database
break;
case 'payment_intent.payment_failed':
// Notify the user, log the failure
break;
}
res.json({ received: true });
}
);
The single most common webhook bug: the route must use express.raw(), not express.json(). Signature verification runs against the exact raw bytes Stripe sent. If a JSON parser touches the body first, constructEvent will reject it every time. Mount the raw parser only on the webhook route.
Test it locally with the Stripe CLI:
stripe listen --forward-to localhost:4242/api/webhooks/stripe
It prints a signing secret starting with whsec_ — use that as your STRIPE_WEBHOOK_SECRET in development.
Step 5: Subscriptions for Recurring Revenue
One-time payments are the easy case. Most SaaS products run on subscriptions. Create a product and price in the Stripe Dashboard, then create a subscription instead of a bare payment intent:
app.post('/api/create-subscription', async (req, res) => {
const { email, priceId } = req.body;
const customer = await stripe.customers.create({ email });
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});
res.json({
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
});
});
On the Vue side, the flow is identical — the same Payment Element and confirmPayment — only the client secret now comes from the subscription’s invoice. Then handle invoice.payment_succeeded, invoice.payment_failed, and customer.subscription.deleted in your webhook to keep access in sync with billing. Those three events cover the bulk of subscription lifecycle management.
Skip the Plumbing
Payment intents, the Elements mount/unmount dance, webhook signature verification, subscription lifecycle events, the customer billing portal — it adds up to a lot of code that has nothing to do with your actual product. Every SaaS needs it, and most of us wire it up from scratch every single time.
Beag gives you auth and Stripe payments as a drop-in. You add both to your Vue app in minutes and get back to the features that make your product worth paying for. If you’re staring down your third Stripe integration, it’s worth a look.
Common Mistakes to Avoid
Calling loadStripe on every render. Call it once in onMounted (or module scope) and keep the reference. Re-loading Stripe.js repeatedly is slow and can break Elements.
Hardcoding the amount in the component. Always compute the charge amount on the server. A user can edit frontend JavaScript; they can’t edit your backend. The payment intent amount is what Stripe actually charges.
Trusting the redirect instead of the webhook. The return_url redirect can be lost. Grant access in your webhook handler when payment_intent.succeeded fires.
Wrapping Stripe objects in ref(). The stripe and elements instances are not reactive data. Keep them as plain variables; only reactive UI state belongs in refs.
FAQ
Is there an official Stripe SDK for Vue?
No. Stripe doesn’t publish a Vue-specific package, but you don’t need one. Load @stripe/stripe-js directly and mount Elements into a ref-bound div. The community vue-stripe-js library offers thin Vue components if you prefer a more declarative wrapper.
Do I need a backend to use Stripe with Vue?
Yes. Payment intents and webhooks require your secret key, which must never reach the browser. A serverless function (Vercel, Netlify, Cloudflare Workers) counts as a backend — you don’t need a full server.
Should I use the Payment Element or Stripe Checkout?
Stripe Checkout is a hosted page Stripe controls — fastest to ship, least customizable. The Payment Element embeds inside your Vue app so you keep your own design. Use Checkout to validate an idea quickly; use the Payment Element when the checkout experience matters.
How do I handle subscriptions in Vue with Stripe?
Create the subscription on your backend and return the client secret from its first invoice’s payment intent. The Vue checkout flow is the same as a one-time payment — then listen for invoice.payment_succeeded and customer.subscription.deleted webhooks to keep access in sync.
Wrapping Up
Adding Stripe to Vue 3 is mostly about respecting the Composition API: load Stripe once, mount the Payment Element into a ref, keep SDK objects out of your reactive state, and confirm payments on submit. The backend handles intents and webhooks the same way it would for any framework.
If you’d rather not maintain all of this, Beag bundles auth and payments so you can ship faster. For the React equivalent of this guide, see adding Stripe payments to React.
Ready to Make Money From Your SaaS?
Turn your SaaS into cash with Beag.io. Get started now!
Start 7-day free trial →