Add Stripe Payments to Svelte: Setup Guide (2026)
Add Stripe payments to your Svelte app with the Payment Element, payment intents, and webhooks. Working SvelteKit code examples.
You built something in Svelte, users want to pay for it, and now you need to add Stripe payments to your Svelte app. Stripe’s docs are React-first, so Svelte developers end up porting examples by hand. This guide gives you the Svelte and SvelteKit version directly — mounting the Payment Element, creating payment intents in a server route, and verifying webhooks — with code that runs on Svelte 5.
Stripe doesn’t publish a Svelte SDK, and you don’t need one. You load @stripe/stripe-js and mount Stripe Elements into a DOM node. If you want thin Svelte components around Elements, the svelte-stripe library exists, but the official library plus a few $state runes does the whole job.
What You Need First
- A Stripe account with your publishable and secret keys (test mode first)
- A SvelteKit app (the standard full-stack Svelte setup, where server routes hold your secret key)
- If you’re on plain Svelte without SvelteKit, any backend endpoint that can create payment intents
SvelteKit is the natural fit here because its +server.js endpoints always run on the server, which is exactly where your Stripe secret key needs to live.
Step 1: Install the Stripe Libraries
npm install @stripe/stripe-js stripe
@stripe/stripe-js runs in the browser and renders the Payment Element. stripe is the Node SDK you’ll use in your SvelteKit server routes.
Step 2: Create a Payment Intent in a Server Route
A Payment Intent represents one payment from start to finish. Your server creates it and returns the client_secret. The amount is set server-side — a value sent from the browser can be tampered with, so you never trust it.
// src/routes/api/create-payment-intent/+server.js
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '$env/static/private';
import { json } from '@sveltejs/kit';
const stripe = new Stripe(STRIPE_SECRET_KEY);
export async function POST() {
const paymentIntent = await stripe.paymentIntents.create({
amount: 1999, // $19.99 in cents — calculated on the server
currency: 'usd',
automatic_payment_methods: { enabled: true },
});
return json({ clientSecret: paymentIntent.client_secret });
}
$env/static/private guarantees the secret key never gets bundled into client code — SvelteKit throws at build time if you try to import it into the browser. That’s a real safety net.
Step 3: Build the Checkout Component
Here’s the Svelte-specific part. You load Stripe, create an Elements instance once you have a client secret, and mount the Payment Element into an element captured with bind:this. The onMount lifecycle handles setup, and Svelte 5 runes manage the reactive UI state.
<!-- src/lib/CheckoutForm.svelte -->
<script>
import { onMount } from 'svelte';
import { loadStripe } from '@stripe/stripe-js';
import { PUBLIC_STRIPE_KEY } from '$env/static/public';
let paymentElementNode;
let message = $state('');
let isProcessing = $state(false);
let stripe;
let elements;
onMount(async () => {
stripe = await loadStripe(PUBLIC_STRIPE_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(paymentElementNode);
});
async function handleSubmit() {
if (!stripe || !elements) return;
isProcessing = true;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment-success`,
},
});
if (error) message = error.message ?? 'Something went wrong.';
isProcessing = false;
}
</script>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div bind:this={paymentElementNode}></div>
<button disabled={isProcessing}>
{isProcessing ? 'Processing…' : 'Pay now'}
</button>
{#if message}<p>{message}</p>{/if}
</form>
Note that stripe and elements are plain let variables, not $state runes — they’re SDK objects, and there’s no reason to make them reactive. Only message and isProcessing drive the UI, so only those use $state. The Payment Element mounts into the bind:this node after the client secret arrives.
The Payment Element renders 25+ payment methods (cards, Apple Pay, Google Pay, Link) in a single component and shows the most relevant ones for each customer automatically. Card data lives inside Stripe’s iframe, so PCI compliance stays on Stripe’s side.
PUBLIC_STRIPE_KEY uses the PUBLIC_ prefix because SvelteKit only exposes prefixed env vars to the browser — it’s your publishable key, which is safe to ship.
Step 4: Handle Webhooks
The return_url redirect can be lost — a closed tab, a dropped connection — so the authoritative signal that a payment succeeded is the webhook, not the redirect. You grant access from the webhook handler.
// src/routes/api/webhooks/stripe/+server.js
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from '$env/static/private';
const stripe = new Stripe(STRIPE_SECRET_KEY);
export async function POST({ request }) {
const sig = request.headers.get('stripe-signature');
const body = await request.text(); // raw body — do NOT parse as JSON
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
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;
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
The SvelteKit-specific gotcha: read the body with await request.text(), not request.json(). Signature verification checks the exact raw bytes Stripe sent. Parsing to JSON first changes those bytes and constructEvent will reject every event. This is the number one reason Stripe webhooks fail in SvelteKit.
Test locally with the Stripe CLI:
stripe listen --forward-to localhost:5173/api/webhooks/stripe
It prints a whsec_ signing secret — set it as STRIPE_WEBHOOK_SECRET while developing.
Step 5: Subscriptions
Most SaaS revenue is recurring. Create a product and price in the Dashboard, then create a subscription instead of a one-off intent:
// src/routes/api/create-subscription/+server.js
export async function POST({ request }) {
const { email, priceId } = await request.json();
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'],
});
return json({
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
});
}
The Svelte checkout component doesn’t change — same Payment Element, same confirmPayment. The client secret just comes from the subscription’s first invoice. Then handle invoice.payment_succeeded, invoice.payment_failed, and customer.subscription.deleted in your webhook to keep access aligned with billing.
Skip the Boilerplate
Payment intents, the mount/unmount Elements lifecycle, raw-body webhook verification, subscription events, the billing portal — every SaaS app needs all of it, and none of it is your product. It’s the same code in every project, written from scratch every time.
Beag bundles auth and Stripe payments into a drop-in you can add to a Svelte app in minutes. If you’ve wired up Stripe before and don’t want to do it again, that’s the shortcut. Otherwise the code above gets you there.
Common Mistakes to Avoid
Parsing the webhook body as JSON. Use request.text() and pass the raw string to constructEvent. JSON parsing breaks signature verification.
Hardcoding the amount in the component. Calculate it in your +server.js route. Frontend values can be edited; the payment intent amount is what Stripe charges.
Exposing the secret key. Import it only from $env/static/private. The PUBLIC_ prefix is exclusively for the publishable key.
Relying on the redirect for fulfillment. Grant access in the webhook handler on payment_intent.succeeded, not when the browser hits return_url.
FAQ
Is there an official Stripe SDK for Svelte?
No. Load @stripe/stripe-js and mount Elements into a bind:this node. The community svelte-stripe library wraps Elements in Svelte components if you want a declarative API, but it’s optional.
Do I need SvelteKit, or does plain Svelte work?
Plain Svelte works for the frontend, but you still need a backend to create payment intents and verify webhooks with your secret key. SvelteKit is convenient because its +server.js routes run server-side by default, but any backend will do.
How do I keep my Stripe secret key safe in SvelteKit?
Import it from $env/static/private. SvelteKit refuses to bundle private env vars into client code and errors at build time if you try, so the key stays server-only.
Should I use Stripe Checkout or the Payment Element in Svelte?
Stripe Checkout is a hosted page — quickest to ship, least control over design. The Payment Element embeds in your Svelte app so the checkout matches your UI. Start with Checkout to validate; move to the Payment Element when the experience matters.
Wrapping Up
Adding Stripe to Svelte comes down to loading Stripe once, mounting the Payment Element into a bind:this node, keeping SDK objects out of your runes, and reading the raw body in your webhook route. SvelteKit’s server routes and $env modules make the secret-handling clean.
If you’d rather not maintain this yourself, Beag handles auth and payments end to end. For more framework guides, browse the Beag blog.
Ready to Make Money From Your SaaS?
Turn your SaaS into cash with Beag.io. Get started now!
Start 7-day free trial →