Use Vue Stripe with Nuxt and SSR
Render pages that use Vue Stripe on the server. This page lists what every server-rendered app needs, then sets up Nuxt step by step. Other setups have their own pages:
- Use Vue Stripe in a Vite SSR app, for your own server with
vue/server-renderer - Use Vue Stripe in VitePress
- Quasar SSR, below
- Astro, where each Vue island is a separate app
Stripe.js only runs in the browser. Vue Stripe never loads it on the server: VueStripeProvider loads Stripe.js in onMounted, and the plugin loads it the first time a component reads it in the browser. During server rendering every provider renders its #loading slot, and the browser's first render matches it, so hydration does not mismatch. After hydration Stripe.js loads and the elements mount.
No bundler setting is needed: the package loads in Node as it is. What you set up is where the keys live, and where the client secret is fetched.
What every SSR app needs
Fetch client secrets in the browser
setup() and <script setup> also run on the server. A request started there runs on every server render, and a relative URL such as fetch('/api/checkout-elements-session') has no origin on the server, so Node's fetch rejects with "Failed to parse URL". When nothing handles that rejection, Node exits: one page view stops a Vite SSR or Quasar SSR server, in development and in production. Nuxt catches the rejection and logs it.
A Promise passed to client-secret of a Checkout provider is handled only when the provider renders on the server, which it does when the plugin provides Stripe. The server then keeps running and logs [Vue Stripe] Checkout init error: Failed to parse URL from /api/... on every render (nothing, when the provider has an #error slot or an @error listener). Inside VueStripeProvider, which renders only its loading slot on the server, nothing handles it, and the server stops.
VueStripeElements: fetch the client secret inonMounted, which runs only in the browser, as the Payment Element guide does. In Nuxt,useFetch(..., { server: false })does the same.VueStripeCheckoutElementsProvider,VueStripeCheckoutFormProviderandVueStripeEmbeddedCheckout: don't pass a Promise created at the top of<script setup>inclient-secret. Pass a function infetch-client-secret. The provider calls it in the browser only, once Stripe.js has loaded:
<!-- CheckoutPage.vue -->
<script setup lang="ts">
import { VueStripeProvider, VueStripeCheckoutElementsProvider } from '@vue-stripe/vue-stripe'
import CheckoutForm from './CheckoutForm.vue'
// Nuxt: useRuntimeConfig().public.stripePublishableKey (see below)
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
// 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>
<VueStripeProvider :publishable-key="publishableKey">
<VueStripeCheckoutElementsProvider :fetch-client-secret="fetchClientSecret">
<CheckoutForm />
<template #loading>Loading checkout...</template>
<template #error="{ error }">Checkout could not load: {{ error }}</template>
</VueStripeCheckoutElementsProvider>
</VueStripeProvider>
</template>CheckoutForm is the form from Build your own checkout page on a Checkout Session. The server renders the providers' loading state, and the browser creates one session per visit.
The sample reads the key as a Vite app does. In Nuxt, write const publishableKey = useRuntimeConfig().public.stripePublishableKey, as the comment says: the Nuxt setup below puts the key in NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY is undefined there, every render of the page answers 500 "publishableKey or stripeKey is required", and nuxi generate stops with a prerender error. The same change applies to every sample in these docs that reads import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY. With the plugin from step 3, drop VueStripeProvider and the key line instead.
Set the publishable key where the server can read it
VueStripeProvider throws "publishableKey or stripeKey is required" during setup when its key is empty. In Nuxt, and on a Vite development server, that turns the whole response into an HTTP 500 before any browser code runs. A Vite SSR production server and vitepress build log the error instead and render nothing in the provider's place, so the page answers 200 without its payment form. The server process needs the key in production as well as in development; each setup below says where it reads it from. The plugin does not throw: with an empty key the page renders, and the error appears in the browser when Stripe.js loads (VueStripePricingTable shows it through its #error slot from the server render on).
Nuxt
The paths below follow Nuxt 4, which reads plugins, pages and components from the app/ directory. On Nuxt 3, or in a Nuxt 4 project that keeps the Nuxt 3 layout (no app/ directory), drop the app/ prefix: plugins/, pages/ and components/ sit at the project root. server/ stays at the root in both.
1. Install the packages
Install Vue Stripe and Stripe.js, plus Stripe's Node library for the server routes:
npm install @vue-stripe/vue-stripe@^6 @stripe/stripe-js@^10 stripeNuxt builds without stripe installed; the server routes then fail at runtime with ERR_MODULE_NOT_FOUND.
2. Configure Nuxt
// nuxt.config.ts
export default defineNuxtConfig({
css: ['@vue-stripe/vue-stripe/style.css'],
runtimeConfig: {
// Server only
stripeSecretKey: '',
stripeWebhookSecret: '',
public: {
stripePublishableKey: ''
}
}
})Set the keys in .env. Nuxt maps NUXT_-prefixed variables onto runtimeConfig, and only public values reach the browser:
NUXT_STRIPE_SECRET_KEY=sk_test_...
NUXT_STRIPE_WEBHOOK_SECRET=whsec_...
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...You get the whsec_... webhook secret in step 7; leave it empty until then.
Read the publishable key as useRuntimeConfig().public.stripePublishableKey, as the steps below do. Samples on the other pages of these docs read import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY, which is undefined with this .env; replace that line when you copy one.
nuxi dev reads .env; the production server does not. Set the variables in the server's environment, or pass the file to Node when you start the build:
npx nuxi build
node --env-file=.env .output/server/index.mjsWithout them every page that renders VueStripeProvider answers 500 "publishableKey or stripeKey is required", and the server routes fail with an empty secret key. With the plugin from step 3 instead, the pages answer 200, and each element shows Stripe.js' message "Please call Stripe() with your publishable key. You used an empty string.": the plugin gets the '' default from nuxt.config.ts.
3. Provide Stripe
Either install the plugin once, so every page can use the elements and composables without a provider:
// app/plugins/vue-stripe.ts
import { createVueStripe } from '@vue-stripe/vue-stripe'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
nuxtApp.vueApp.use(
createVueStripe({ publishableKey: config.public.stripePublishableKey })
)
})or wrap the payment part of each page in VueStripeProvider, as in steps 5 and 6 below.
4. Create the Intent in a server route
// server/api/payment-intent.post.ts
import Stripe from 'stripe'
export default defineEventHandler(async () => {
const stripe = new Stripe(useRuntimeConfig().stripeSecretKey)
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
})
return { clientSecret: paymentIntent.client_secret }
})The amount, $20.00, matches the "Pay $20.00" button of the PaymentForm in step 5. Change both together.
5. Render the payment form
Fetch the client secret in the browser (server: false), so a page render does not create a PaymentIntent:
<!-- app/pages/checkout/index.vue -->
<script setup lang="ts">
import { VueStripeProvider, VueStripeElements } from '@vue-stripe/vue-stripe'
const config = useRuntimeConfig()
const { data, error } = await useFetch('/api/payment-intent', { method: 'POST', server: false })
</script>
<template>
<p v-if="error">Could not start the payment. Please reload the page.</p>
<VueStripeProvider v-else :publishable-key="config.public.stripePublishableKey">
<VueStripeElements v-if="data?.clientSecret" :client-secret="data.clientSecret">
<!-- app/components/PaymentForm.vue renders the Payment Element and calls usePaymentIntent() -->
<PaymentForm :client-secret="data.clientSecret" />
</VueStripeElements>
</VueStripeProvider>
</template>PaymentForm is the component from Accept a payment with the Payment Element. With the plugin from step 3, drop the VueStripeProvider wrapper.
Name the file checkout/index.vue, not checkout.vue. Nuxt treats app/pages/checkout/complete.vue, the Checkout Session return page below, as a child route of checkout.vue, and because that page has no <NuxtPage />, /checkout/complete would show the payment form again.
When the server route fails (a wrong secret key, Stripe unreachable), useFetch sets error and leaves data empty; the page then shows the message instead of an empty form.
Check data?.clientSecret, not only data. useFetch takes its type from the server route, and stripe-node types client_secret as string | null, so nuxi typecheck rejects data.clientSecret as a client-secret until the template has ruled out null.
ref, useFetch and the other Nuxt and Vue functions are auto-imported. Import the Vue Stripe components yourself, or register them in a plugin.
6. Show the result on the return page
PaymentForm confirms with return_url: ${window.location.origin}/payment-complete, so after a successful payment Stripe sends the customer to /payment-complete, with payment_intent_client_secret in the query string. Read the result there in a component inside the provider:
<!-- app/components/PaymentStatus.vue -->
<script setup lang="ts">
import { usePaymentIntent } from '@vue-stripe/vue-stripe'
const route = useRoute()
const { retrievePaymentIntent } = usePaymentIntent()
const status = ref('')
onMounted(async () => {
const clientSecret = route.query.payment_intent_client_secret
if (typeof clientSecret !== 'string') return
const { paymentIntent, error } = await retrievePaymentIntent(clientSecret)
status.value = error ? 'unknown' : (paymentIntent?.status ?? 'unknown')
})
</script>
<template>
<p v-if="status === 'succeeded'">Payment received.</p>
<p v-else-if="status === 'processing'">Your payment is processing. We will email you when it completes.</p>
<p v-else-if="status === 'requires_payment_method'">The payment failed. Please try another payment method.</p>
<p v-else-if="status">We could not check the payment status. Please contact us.</p>
</template><!-- app/pages/payment-complete.vue -->
<script setup lang="ts">
import { VueStripeProvider } from '@vue-stripe/vue-stripe'
const config = useRuntimeConfig()
</script>
<template>
<VueStripeProvider :publishable-key="config.public.stripePublishableKey">
<PaymentStatus />
</VueStripeProvider>
</template>With the plugin, render <PaymentStatus /> without the provider. Use this page to inform the customer only; fulfil orders from webhooks.
7. Receive webhooks
Fulfil orders from a server route, as Fulfil orders with webhooks explains. Verify the signature against the raw body, which readRawBody() returns, and read the signing secret from runtimeConfig (step 2):
// server/routes/webhook.post.ts (POST /webhook)
import Stripe from 'stripe'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const stripe = new Stripe(config.stripeSecretKey)
let stripeEvent: Stripe.Event
try {
stripeEvent = stripe.webhooks.constructEvent(
(await readRawBody(event)) ?? '',
getHeader(event, 'stripe-signature') ?? '',
config.stripeWebhookSecret
)
} catch {
throw createError({ statusCode: 400, statusMessage: 'Webhook signature check failed' })
}
switch (stripeEvent.type) {
case 'payment_intent.succeeded':
// fulfil the order in metadata.orderId, which your server set on the PaymentIntent;
// skip one without it: the PaymentIntent of a Checkout payment has none
break
case 'checkout.session.completed':
// fulfil the order in client_reference_id, set on the Checkout Session
break
}
return { received: true }
})Don't use readBody(): it parses the JSON, and the parsed body no longer matches the signature. readRawBody() and getHeader() return string | undefined, which constructEvent() does not accept, hence the ?? ''; a request without a body or signature then fails the check with 400. Read the secret with useRuntimeConfig(), not process.env, which nuxi typecheck does not know in a server route.
Forward test-mode events to the route with the Stripe CLI, and put the whsec_... secret that the command prints in NUXT_STRIPE_WEBHOOK_SECRET (nuxi dev restarts by itself when .env changes):
stripe listen --events payment_intent.succeeded,checkout.session.completed --forward-to localhost:3000/webhookPay on /checkout again: stripe listen shows payment_intent.succeeded answered with [200]. A Checkout payment sends both events, so fulfil it from checkout.session.completed only; the webhook guide explains why and lists the events for each integration.
Build the page on a Checkout Session instead
VueStripeCheckoutElementsProvider and VueStripeCheckoutFormProvider take the client secret as a string or a Promise. In Nuxt, pass a function in fetch-client-secret instead, as in every SSR app: a Promise created at the top of <script setup> would start the request on the server for every render.
Create the session in a server route. Checking
client_secretgives the page astring, and catches a session created withoutui_mode: 'elements':ts// server/api/checkout-elements-session.post.ts import Stripe from 'stripe' export default defineEventHandler(async (event) => { const stripe = new Stripe(useRuntimeConfig().stripeSecretKey) const session = await stripe.checkout.sessions.create({ ui_mode: 'elements', mode: 'payment', line_items: [{ price: 'price_123', quantity: 1 }], return_url: `${getRequestURL(event).origin}/checkout/complete?session_id={CHECKOUT_SESSION_ID}` }) if (!session.client_secret) { throw createError({ statusCode: 500, statusMessage: 'Checkout Session has no client secret' }) } return { clientSecret: session.client_secret } })Pass the request as a function:
vue<!-- app/pages/custom-checkout.vue --> <script setup lang="ts"> import { VueStripeProvider, VueStripeCheckoutElementsProvider } from '@vue-stripe/vue-stripe' const config = useRuntimeConfig() // Called by the provider in the browser only, once Stripe.js has loaded. const fetchClientSecret = () => $fetch('/api/checkout-elements-session', { method: 'POST' }).then((data) => data.clientSecret) </script> <template> <VueStripeProvider :publishable-key="config.public.stripePublishableKey"> <VueStripeCheckoutElementsProvider :fetch-client-secret="fetchClientSecret"> <!-- app/components/CheckoutForm.vue uses useCheckoutSession() --> <CheckoutForm /> <template #loading>Loading checkout...</template> <template #error="{ error }">Checkout could not load: {{ error }}</template> </VueStripeCheckoutElementsProvider> </VueStripeProvider> </template>CheckoutFormis the form from Build your own checkout page on a Checkout Session.VueStripeCheckoutFormProviderandVueStripeEmbeddedCheckouttake the samefetch-client-secretprop.Check the session on the return page. Stripe redirects to the session's
return_url,/checkout/complete?session_id=.... Read the session on the server, with the same route as Embed Stripe Checkout:ts// server/api/checkout-status.get.ts import Stripe from 'stripe' export default defineEventHandler(async (event) => { const stripe = new Stripe(useRuntimeConfig().stripeSecretKey) const { session_id } = getQuery(event) if (typeof session_id !== 'string') { throw createError({ statusCode: 400, statusMessage: 'Missing session_id' }) } try { const session = await stripe.checkout.sessions.retrieve(session_id) return { status: session.status, paymentStatus: session.payment_status } } catch { throw createError({ statusCode: 404, statusMessage: 'No such Checkout Session' }) } })vue<!-- app/pages/checkout/complete.vue --> <script setup lang="ts"> const route = useRoute() const { data } = await useFetch('/api/checkout-status', { query: { session_id: route.query.session_id } }) </script> <template> <p v-if="data?.paymentStatus === 'paid'">Payment received.</p> <p v-else-if="data?.status === 'complete'">Your payment is processing. We will email you when it completes.</p> <p v-else-if="data?.status === 'open'">The payment did not complete. <NuxtLink to="/custom-checkout">Try again</NuxtLink></p> <p v-else>We could not check the payment status. Please contact us.</p> </template>An unknown or mistyped
session_idmakesretrieve()throw. The route answers 404 withcreateError(), as the Express route does, so Nitro does not log it as an unhandled error, and the page shows its fallback message.This page needs no Stripe.js, so it renders on the server. It works because the payment page is
checkout/index.vue(step 5); next to acheckout.vueit would render as that page's child. Withnuxi generate, turn off server rendering for it, as Static hosting withnuxi generateexplains.
Render on the client only
You don't have to, but if you prefer to send no Stripe markup from the server, wrap the form in <ClientOnly> (with a #fallback template) or move it into a component named *.client.vue.
Static hosting with nuxi generate
nuxi generate prerenders every page into .output/public for a static host. The server routes are not part of it: server/api/* and server/routes/* run only in the server that nuxi build creates. On a static host POST /api/payment-intent answers 404, so the payment page shows "Could not start the payment", /custom-checkout shows "Checkout could not load: ... 404 Not Found", and Stripe's webhook events have nowhere to go.
Without a server only the browser side works: Stripe.js loads, and /payment-complete reads the PaymentIntent in the browser. Everything that uses your secret key needs a server. Choose one:
- Keep the server. Deploy with
nuxi build: to a Node server, as in step 2, or to a host that runs the server routes as serverless or edge functions, with that host's deployment preset. - Run the server routes elsewhere. Generate the site, and call the routes on your API server by their full URL: add
apiBase: ''toruntimeConfig.public, setNUXT_PUBLIC_API_BASEto the API server's origin (such ashttps://api.example.com), and passbaseURL: config.public.apiBaseto everyuseFetch()and$fetch()that calls it. The API server must allow your site's origin (CORS), buildreturn_urlfrom your site's URL instead ofgetRequestURL(event), and receive the webhooks.
nuxi generate reads .env and writes the public values, NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY among them, into the generated pages. Set them before you generate; changing them later needs a new nuxi generate.
The Checkout Session return page needs one more change. app/pages/checkout/complete.vue reads the session while it renders. nuxi generate prerenders it once, without a session_id, writes "We could not check the payment status" into the HTML and its payload, and the browser shows that for every session, paid or not. Turn off server rendering for that page, so the browser renders it and reads the session itself:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/checkout/complete': { ssr: false }
}
// ...css, runtimeConfig
})nuxi generate then writes an empty shell for /checkout/complete, and the page renders in the browser with the real session_id. Switching the page's useFetch() to server: false instead is not enough: a prerendered page hydrates at its prerendered URL, so route.query.session_id is still empty when the request starts.
Quasar SSR
The full Quasar setup (the QCLI_ key prefix, a boot file, the CSP, the dev proxy and an SSR middleware for /api) is in Use Vue Stripe with Quasar. Use the components as in any Vue app, and fetch client secrets in the browser as in every SSR app. A request started at the top of <script setup> runs on every server render. A bare fetch() there stops the Quasar SSR server, and so does a Promise in client-secret inside VueStripeProvider; under the boot file's plugin, such a Promise makes the server log [Vue Stripe] Checkout init error: Failed to parse URL from /api/... on every render instead. <q-no-ssr> around the form is optional, as <ClientOnly> is in Nuxt:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { VueStripeProvider, VueStripeElements, VueStripePaymentElement } from '@vue-stripe/vue-stripe'
defineProps<{ publishableKey: string }>()
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>
<q-no-ssr>
<p v-if="errorMessage">{{ errorMessage }}</p>
<VueStripeProvider v-else :publishable-key="publishableKey">
<VueStripeElements v-if="clientSecret" :client-secret="clientSecret">
<VueStripePaymentElement />
</VueStripeElements>
</VueStripeProvider>
</q-no-ssr>
</template>Render VueStripeElements only once the client secret has arrived (v-if): created without one, the Payment Element fails with a [Vue Stripe] error in the console. When the route fails, the page shows "Failed to initialize payment", as the Quasar guide's payment page does; without the response.ok check, an error answer such as Hono's plain-text "Internal Server Error" makes response.json() throw instead, and the page stays empty.
VitePress
See Use Vue Stripe in VitePress: where VitePress reads the key, the plugin in enhanceApp, the dev proxy and the API of a static build.
If something goes wrong
The server process exits after one request
Node prints TypeError: Failed to parse URL from /api/... and stops. A component starts a request with a relative URL during server rendering, and nothing handles its rejection: a fetch() at the top of <script setup>, or a Promise passed to client-secret of a Checkout provider inside VueStripeProvider. Pass a function in fetch-client-secret, or fetch in onMounted; see Fetch client secrets in the browser.
When the plugin provides Stripe, the same Promise does not stop the server: the provider handles the rejection, and the server logs [Vue Stripe] Checkout init error: Failed to parse URL from /api/... (Checkout Form init error for VueStripeCheckoutFormProvider) on every render. The fix is the same.
Every page answers 500 "publishableKey or stripeKey is required"
The server rendered VueStripeProvider with an empty key. In Nuxt's production server, set NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in the environment or start it with node --env-file=.env (step 2). When only the pages copied from other guides answer 500, in nuxi dev too, they read import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY; read useRuntimeConfig().public.stripePublishableKey instead. A Vite SSR app answers 500 from its development server only, and its key must be set when you run vite build; see the Vite SSR troubleshooting entry.
The server fails with ERR_UNSUPPORTED_DIR_IMPORT
Version 6.0.0-beta.0 imports @stripe/stripe-js/pure as a directory, which Node's ES module loader rejects when the server loads the package from node_modules. Update to 6.0.0-beta.1 or later. If you must stay on 6.0.0-beta.0, bundle the package for the server as a workaround: build.transpile: ['@vue-stripe/vue-stripe'] in Nuxt, ssr.noExternal: ['@vue-stripe/vue-stripe'] in Vite (vite.ssr.noExternal in VitePress).
Hydration mismatch warnings
If you see a hydration mismatch, look for your own code that reads browser-only values (window, localStorage, the current time) during render, and move it into onMounted.
Result
The server renders the page, including the providers' loading state, without loading Stripe.js. In the browser the page hydrates, Stripe.js loads once, and the elements appear. After paying, the customer lands on a return page that shows the result.
