Use Vue Stripe in a Vite SSR app
Add Vue Stripe to a Vue 3 app that you server-render yourself with Vite and vue/server-renderer, and take a test payment. The steps start from Vite's SSR template, which has an Express server (server.js) and the entries src/entry-server.ts and src/entry-client.ts:
npm create vite-extra@latest my-app -- --template ssr-vue-tsThe rules in What every SSR app needs apply here. In a plain Node server they matter most: a request started at the top of <script setup> runs on every server render, and when nothing handles its rejection, it stops the server process.
1. Install the packages
Install Vue Stripe, Stripe.js, Stripe's Node library for the API routes, and Vue Router for the return page:
npm install @vue-stripe/vue-stripe@^6 @stripe/stripe-js@^10 stripe vue-routerThe server loads the package from node_modules as an ES module. No ssr.noExternal or other Vite setting is needed.
2. Add the keys
Put both keys in .env.local in the project root:
STRIPE_SECRET_KEY=sk_test_...
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...Vite gives app code only the VITE_ variable, and vite build writes its value into the client and server bundles, so the publishable key must be set when you build (without it, the elements show an error). The secret key stays in the Node server, which reads the file in step 4.
3. Create the app per request
The server renders every request with a new app. Create the router and install the plugin inside createApp(), never at module level, so no state is shared between requests:
// src/main.ts
import { createSSRApp } from 'vue'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
import { createVueStripe } from '@vue-stripe/vue-stripe'
import '@vue-stripe/vue-stripe/style.css'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const router = createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes: [
{ path: '/', component: () => import('./pages/CheckoutPage.vue') },
{ path: '/payment-complete', component: () => import('./pages/PaymentComplete.vue') }
]
})
app.use(router)
// On the server the plugin only provides; Stripe.js loads in the browser.
app.use(createVueStripe({ publishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY }))
return { app, router }
}The stylesheet import can stay here: Vite leaves CSS out of the server bundle.
Render the route in src/App.vue:
<!-- src/App.vue -->
<template>
<RouterView />
</template>Then let both entries wait for the router:
// src/entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(url: string) {
const { app, router } = createApp()
await router.push(url)
await router.isReady()
const html = await renderToString(app, {})
return { html }
}// src/entry-client.ts
import { createApp } from './main'
const { app, router } = createApp()
router.isReady().then(() => app.mount('#app'))4. Serve the API from the same server
Add the API routes to server.js, before the Vite middleware and the catch-all route that renders pages. The app then calls /api on its own origin in development and in production, and needs no proxy:
// server.js
import fs from 'node:fs/promises'
import express from 'express'
import Stripe from 'stripe'
// ...the template's constants and template HTML...
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
app.post('/api/create-payment-intent', async (req, res) => {
const paymentIntent = await stripe.paymentIntents.create({
amount: 2000, // decide the amount on the server
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { orderId: 'order_123' } // your own order ID, read back by the webhook
})
res.json({ clientSecret: paymentIntent.client_secret })
})
// ...the template's Vite or production middlewares, then app.use('*all', ...)Node does not read .env.local by itself. Pass the file in the scripts in package.json:
{
"scripts": {
"dev": "node --env-file=.env.local server",
"preview": "cross-env NODE_ENV=production node --env-file=.env.local server"
}
}On a production host, set STRIPE_SECRET_KEY in the environment instead and start the server with NODE_ENV=production node server.
If your API runs as a separate server, the dev proxy in vite.config.ts also works here, because server.js runs Vite in middleware mode. In production, forward /api from server.js or a reverse proxy yourself: the proxy is part of the dev server only.
5. Render the payment form
Fetch the client secret in onMounted. It runs in the browser only, so a server render creates no PaymentIntent and the server renders the loading text:
<!-- src/pages/CheckoutPage.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VueStripeElements } from '@vue-stripe/vue-stripe'
import PaymentForm from '../components/PaymentForm.vue'
const clientSecret = ref('')
const errorMessage = ref('')
onMounted(async () => {
try {
const response = await fetch('/api/create-payment-intent', { method: 'POST' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
clientSecret.value = (await response.json()).clientSecret
} catch {
// An error answer, or no answer at all (server down, offline)
errorMessage.value = 'Failed to initialize payment'
}
})
</script>
<template>
<p v-if="errorMessage">{{ errorMessage }}</p>
<VueStripeElements v-else-if="clientSecret" :client-secret="clientSecret">
<PaymentForm :client-secret="clientSecret" />
</VueStripeElements>
<p v-else>Loading...</p>
</template>src/components/PaymentForm.vue is the component from Accept a payment with the Payment Element. Its button says "Pay $20.00", the amount the server route charges. The plugin provides Stripe, so the page needs no VueStripeProvider.
6. Add the return page
PaymentForm confirms with return_url: ${window.location.origin}/payment-complete. Use CompletePage.vue from Check the result on the return page as src/pages/PaymentComplete.vue. It reads the result in onMounted, and the plugin gives it Stripe without a provider.
7. Run it
Start the development server and open
http://localhost:5173:bashnpm run devPay with the test card
4242 4242 4242 4242, any future expiry date and any CVC. Stripe redirects to/payment-complete, which shows "Payment received."Build and run the production server:
bashnpm run build npm run preview
Build the page on a Checkout Session instead
For VueStripeCheckoutElementsProvider, VueStripeCheckoutFormProvider and VueStripeEmbeddedCheckout, pass a function in fetch-client-secret. The provider calls it in the browser only, once Stripe.js has loaded. Don't pass a Promise created at the top of <script setup> in client-secret: it starts the request on every server render, where the relative fetch('/api/checkout-elements-session') rejects with "Failed to parse URL". With the plugin from step 3 the provider handles that rejection and logs it on every render; inside a VueStripeProvider nothing handles it, and the server process exits.
The steps below add a page on a Checkout Session with your own form at /checkout, next to the PaymentIntent page at /, and its return page at /checkout/complete.
Add the session routes to
server.js, next to the PaymentIntent route:js// server.js app.post('/api/checkout-elements-session', async (req, res) => { const session = await stripe.checkout.sessions.create({ ui_mode: 'elements', mode: 'payment', line_items: [{ price: 'price_123', quantity: 1 }], return_url: `${req.protocol}://${req.get('host')}/checkout/complete?session_id={CHECKOUT_SESSION_ID}` }) res.json({ clientSecret: session.client_secret }) }) // GET /api/checkout-status?session_id=cs_test_..., read by CheckoutComplete.vue app.get('/api/checkout-status', async (req, res) => { try { const session = await stripe.checkout.sessions.retrieve(req.query.session_id) res.json({ status: session.status, paymentStatus: session.payment_status }) } catch { res.status(404).json({ error: 'No such Checkout Session' }) } })Replace
price_123with the ID of a Price in your Stripe account.server.jsserves the pages and the API on one origin, so the request's host is the address in the customer's browser.Save the page as
src/pages/SessionCheckoutPage.vue. The plugin provides Stripe, so it needs noVueStripeProvider:vue<!-- src/pages/SessionCheckoutPage.vue --> <script setup lang="ts"> import { VueStripeCheckoutElementsProvider } from '@vue-stripe/vue-stripe' import CheckoutForm from '../components/CheckoutForm.vue' // Called by the provider in the browser only, once Stripe.js has loaded. const fetchClientSecret = () => fetch('/api/checkout-elements-session', { method: 'POST' }) .then((response) => response.json()) .then((data: { clientSecret: string }) => data.clientSecret) </script> <template> <VueStripeCheckoutElementsProvider :fetch-client-secret="fetchClientSecret"> <CheckoutForm /> <template #loading>Loading checkout...</template> <template #error="{ error }">Checkout could not load: {{ error }}</template> </VueStripeCheckoutElementsProvider> </template>src/components/CheckoutForm.vueis the form from Build your own checkout page on a Checkout Session.Use
SuccessPage.vuefrom Show the outcome on the success page assrc/pages/CheckoutComplete.vue. It readssession_idfrom the URL and asks/api/checkout-statusinonMounted, so the server render makes no request.Add both pages to the routes in
src/main.ts(step 3):ts// src/main.ts routes: [ { path: '/', component: () => import('./pages/CheckoutPage.vue') }, { path: '/payment-complete', component: () => import('./pages/PaymentComplete.vue') }, { path: '/checkout', component: () => import('./pages/SessionCheckoutPage.vue') }, { path: '/checkout/complete', component: () => import('./pages/CheckoutComplete.vue') } ]
Restart npm run dev (Node does not reload server.js by itself), open http://localhost:5173/checkout, and pay with the test card 4242 4242 4242 4242. Stripe redirects to /checkout/complete?session_id=..., which shows "Thank you! Your payment was received." For Embed Stripe Checkout and the Checkout Form, point return_url at the same /checkout/complete?session_id={CHECKOUT_SESSION_ID} page.
If something goes wrong
The server process exits after one request
Node prints TypeError: Failed to parse URL from /api/... and the next request is refused. A component started a request in setup during server rendering, and nothing handled its rejection: a fetch() at the top of <script setup>, or a Promise passed to client-secret of a Checkout provider inside VueStripeProvider. VueStripeProvider renders only its loading slot on the server, so the provider inside it never receives the Promise. Fetch in onMounted, or pass a function in fetch-client-secret.
The server logs "[Vue Stripe] Checkout init error: Failed to parse URL" on every render
The full line is [Vue Stripe] Checkout init error: Failed to parse URL from /api/checkout-elements-session (Checkout Form init error for VueStripeCheckoutFormProvider). A page passes a Promise created at the top of <script setup> to client-secret, and the provider gets Stripe from the plugin. The provider then renders on the server and handles the rejection: the server keeps running, the page answers 200, and in the browser it creates its own session and works. With an #error slot or an @error listener the provider logs nothing, but the server still starts the request on every render. Pass a function in fetch-client-secret instead, as in Build the page on a Checkout Session instead.
The page shows "Expected publishable key to be of type string", or the payment form is missing
VITE_STRIPE_PUBLISHABLE_KEY was not set when the server started or when you built the app. What you see depends on how the page gets Stripe:
- With the plugin (step 3), the server answers 200 and renders the page. In the browser each element shows Stripe.js' message "Expected publishable key to be of type string, got type undefined instead.", which the console logs as
[Vue Stripe] Initialization error. - With
VueStripeProvider, the provider throws "publishableKey or stripeKey is required" while the server renders the page. The development server answers 500 with the stack trace. The production server answers 200: it logs[VueStripeProviderError]and renders nothing in the provider's place, and in the browser the provider throws again, so the payment form is missing.
Set VITE_STRIPE_PUBLISHABLE_KEY in .env.local and restart npm run dev, or run npm run build again: the production bundle keeps the value it was built with.
The server does not start: "Neither apiKey nor config.authenticator provided"
new Stripe() ran without STRIPE_SECRET_KEY. Start the server with --env-file=.env.local (step 4), or set the variable in the server's environment.
Result
The server renders each page with the providers' loading state and never loads Stripe.js. In the browser the page hydrates without mismatch warnings, the Payment Element mounts, and after paying the customer lands on /payment-complete.
