Skip to content

Integrating Stripe with SvelteKit: Dynamic pricing and Payment Security

20 min read

Integrating Stripe into a SvelteKit application, especially when dealing with dynamic product pricing and robust webhook validation, can be challenging. While building a client's business application using SvelteKit and PostgreSQL (with Drizzle ORM), I encountered the need for a streamlined integration with Stripe. The goal was to use minimal dependencies while supporting features such as dynamic pricing, product images, and secure payment verification via webhooks, all without requiring database updates. This article provides a comprehensive guide to this entire process, addressing a gap in existing resources. It should be noted that we cover the concepts here, and as a result, it's framework agnostic. You can use NextJS or NuxtJS in place of SvelteKit since they support server endpoints, form actions, etc.

Sirneij
Sirneij/digibooks
00

A simple demo app that integrates Stripe with SvelteKit

SvelteTypeScriptCSSJavaScriptHTML

To demonstrate this integration, we will build a minimalist digital book application. The application will allow users to browse books, add them to a cart, and purchase them using Stripe. Access to purchased content will be granted based on the email provided during checkout.

You need to create a new SvelteKit application. If you do not have one yet, just follow these commands:

sh

When prompted during npx sv create digibooks:

  • Choose SvelteKit minimal (barebones scaffolding for your new app) when asked Which Svelte app template?.
  • Select your preferred options for other features. For this project, I opted for TypeScript syntax, Prettier, ESLint, Tailwind CSS, and Drizzle ORM (with libSQL).

Also, we will be using the `stripe` library, which should be installed:

sh

When you opt for drizzle ORM while creating the project with npx sv create, src/lib/server/db/index.ts (with a .env entry for DATABASE_URL), src/lib/server/db/schema.ts and drizzle.config.ts alongside db:push, db:migrate and db:studio entries in package.json will be made available. Let's open src/lib/server/db/schema.ts and populate it with:

src/lib/server/db/schema.ts
ts

Just some very basic SQL tables to list books and record purchases linked to customer emails, which can then be used to grant access to the digital content.

To effect this change, run:

sh

and choose the Yes when prompted. Now, our database tables have been created. We will also add a simple "money" formatter that takes into consideration the cents-based pricing in src/lib/utils/helpers.ts:

ts

To support a Turso-hosted SQLite instance, we need to modify src/lib/server/db/index.ts and drizzle.config.ts to avoid this error:

sh

Open up drizzle.config.ts and make it look like this:

drizzle.config.ts
ts

and src/lib/server/db/index.ts:

src/lib/server/db/index.ts
ts

We are conditionally (based on the DATABASE_URL) changing the database dialect to either sqlite or turso. If turso and authToken are required to successfully connect to the instance. The dialect in drizzle.config.ts is conditionally set to turso when a non-file DATABASE_URL is used, which allows dbCredentials to correctly utilize the authToken. If you are not using a Turso SQLite instance, you may not need to worry about this.

Seeding the database

So that we can have enough data to work with, this article's GitHub repository contains two files: `books.json` and `seed.ts` . The former contains some dummy data in JSON, while the latter has the code to load (not optimized) this data into the DB. I also added an entry in the script section of `package.json` where we utilize the experimental node's --experimental-transform-types flag to run the TypeScript file. You can run these commands in succession (locally) after deploying or at the start of development of your app:

sh

This app will not deal with the intricacies of authentication, so there will not be "users". However, we still need a way to keep track of what the current user wants to buy. As a result, we need a carting system that won't need to store its data in the database. We will combine the browser's localStorage and Svelte 5 runes to achieve a reliable reactivity. Create a src/lib/states/carts.svelte.ts and populate it with:

ts

This is a basic CRUD carting process while making items reactive using the power of Svelte 5 rune. We referenced a type here, and this is the definition:

src/lib/types/cart.ts
ts

Just some additional types that will be used later. Now, we can add a simple utility function (not extremely necessary) for the addToCart functionality:

src/lib/utils/helpers.ts
ts

Though we will not delve into the intricacies of styles and tailwindcss stuff as those are not the focus of this article, beware that I made custom changes to the app's `src/app.css` , `src/routes/+layout.svelte` and of course, each of the pages: src/routes/+page.svelte, src/routes/[id]/+page.svelte,src/routes/cart/+page.svelte, and src/routes/purchases/+page.svelte with some glasmorphism effects.

Let's see what src/routes/+page.server.ts looks like:

ts
Svelte automatically generates $types:

SvelteKit automatically generates TypeScript definitions for your routes, including the data shapes for load functions and page props. When you see import type { PageServerLoad } from "./$types"; or use PageData (often imported from ./$types as well, or inferred), these types are derived from your load function's return signature and any route parameters. This provides excellent type safety between your server-side data loading and your client-side Svelte components. If you modify the data structure returned by a load function, SvelteKit's type generation will reflect these changes, helping you catch errors at build time.

It simply fetches data from the DB with search, filtering, and pagination support. To prevent request waterfalls, we use Promise.all to run the counting and data retrieval queries in parallel. This can improve performance. In src/routes/+page.svelte, we simply get this data via the data props and render them, excluding other features:

html

Refer to the repository for the full code and the BookCard and Pagination components. Same goes with src/routes/[id]/+page.svelte where its load function is quite basic:

ts :src/routes/[id]/+page.server.ts:

Then comes the /purchases route, which provides an interface for users to see the products they purchased. It requires that users input their email address. This is the page's +page.server.ts:

src/routes/purchases/+page.server.ts
ts

To reduce trips to the Database, we joined the purchases and books. Its frontend simply displays this data.

In the next subsection, we will finally integrate Stripe!

It's now time to integrate Stripe with our app so that users can securely pay for their books of choice. There are multiple ways to integrate Stripe or accept payments with Stripe:

  • Prebuilt Checkout page: Stripe hosts the payment page. You redirect users to Stripe, and Stripe redirects them back to your site after payment. This is the quickest way to get started and ensures PCI compliance is handled by Stripe.
  • Payment Element: Embeddable UI components that allow you to design a custom payment form on your site while still leveraging Stripe's infrastructure for processing and PCI compliance. Offers more customization than the prebuilt Checkout page.
  • Custom payment flow: Build your entire payment UI from scratch and use Stripe APIs (like Stripe.js on the frontend and the Stripe SDK on the backend) to process payments. This offers maximum control over the user experience but also requires more effort to implement and manage PCI compliance.

In this article, we'll go with the first option. However, there is a plan to extend to the other two in future articles (as well as integrate other payment processors such as Squareup and Braintree).

To start, create a payments.ts file in src/lib/server/payments.ts (you can use server in NextJS):

ts :src/lib/server/payments.ts

To instantiate a Stripe instance, a STRIPE_SECRET_KEY is required — gotten from your Stripe developer dashboard. In this function, we only touched on some of the data accepted by a Stripe checkout session . You can provide much more data depending on your needs. In the code, we supplied the products (books) being paid for alongside their images and descriptions with the unit amount (in cents, stripe takes cents for USD and EUR. It takes whatever is the 1100\frac{1}{100} of your supported currency. This is to avoid floating point issues). The mode is a one-time payment. You could use subscription for recurring payments or setup to charge customers later. We also provided the routes for successful and unsuccessful processing. We even made collecting customers' billing addresses mandatory. After a payment has been confirmed (via a webhook, to be implemented later), we need some data about the books and other things the user bought so we can fullfil them (here create purchases entry(ies)), that's a potential use case of Stripe.Checkout.SessionCreateParams.metadata. It only takes primitives (string, number, null) as objects' values, though that's why the books property in SessionMetadata is a string (JSON string of an array of bookId and quantity). This is just the tip of the iceberg. You can do much more. After supplying these data, Stripe internally sends requests to its API(s), and a checkout session identification is returned alongside other important details such as the section URL (where you should redirect users to for them to make payment). Here is a truncated example response:

json

With that, we can now implement the form action that uses this function:

src/routes/cart/+page.server.ts
ts

We simply retrieve the needed data from the form, slightly process it, and send it to Stripe.

Beware of putting a redirect in try...catch:

Wrapping a redirect in try...catch in SvelteKit form action almost always returns the `catch` block (with errors). You should handle errors using a different approach.

Now, let's see the cart page (only shows the form and URL handling part):

src/routes/cart/+page.svelte
html

Just some styles and progressive form enhancements.

Currently, even after a successful payment, users cannot access what they paid for. This is unfair. While we strive to fulfill every payment made, we need to be careful, though, as people can game the payment flow, providing a fraudulent payment. This is why we didn't create any purchases entry at the point of making payments. One way we could fulfill an order is to manually create the purchase entries from our Stripe dashboard. This is "manual" and hence prone to errors and very tedious. There is an automatic way to do this with Stripe, and it is called webhook — a mechanism that allows one application to notify another application in real-time about specific events. Before we can fulfill orders, we want Stripe to notify us that payments have indeed been made (via its checkout.session.completed or checkout.session.async_payment_succeeded hooks). We need to patiently listen to this, and a good way is via an endpoint. To locally test this, kindly look into this stripe guide . You will need to create a destination (the url of your endpoint where you want stripe to send communications to, for this app, it'll be /api/webhooks/stripe) and you will be prompted to select the hooks you want to listen to as well (I opted for only checkout.session.completed). After this, a webhook secret will be generated for you. Copy it and save it as STRIPE_WEBHOOK_SECRET in your environment variable. Locally, it will be generated when you first run stripe listen --events checkout.session.completed,checkout.session.async_payment_succeeded --forward-to localhost:5173/api/webhooks/stripe (you have a lot of events to listen to!). To "trigger" an event locally, just run stripe trigger checkout.session.completed in another terminal while the listen command is running in another. Your app should also be running. Here is our endpoint code:

src/routes/api/webhooks/stripe/+server.ts
ts

To avoid fraudulent event notifications, we ensure the event is signed and verified. Unsigned and/or unverified events are outrightly rejected. After that, the event is constructed, and since we are only interested in checkout.session.completed (covers most successful one-time payments) and async_payment_succeeded (important for asynchronous payment methods (e.g., some bank transfers)) type, that's what we listen to. From there, we retrieve the details we sent previously during payment (from metadata) and from it create purchases entries. This is done in an idempotent way so that webhook retries by Stripe or anything won't create duplicates, which would result in losing money.

That's it! Thank you for your time!

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities to impact and learn, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and X . I am also an email away.