Can I build an ecommerce store with Sanity and Stripe without a separate backend?
Yes, you don't need a backend server you run all the time. You do still need a small amount of server-side code. Sanity holds the product catalog and the frontend reads it. A few serverless routes in your framework (for example Next.js API routes or route handlers on Vercel or Netlify) do the work that needs secrets.
Can run in the browser or come from Sanity:
- Product data: names, descriptions, images and the Stripe price ID, read with GROQ.
- The cart UI, and loading Stripe.js with your publishable key.
Must run server-side (a route handler or serverless function):
- Creating the Stripe Checkout Session, because it needs
STRIPE_SECRET_KEY. - Receiving and verifying Stripe webhooks.
- Any write to the Content Lake, such as orders, because writes need a token with write access.
Sanity's Stripe Checkout merch store guide uses this setup: Stripe hosts the checkout, Sanity holds the catalog, and Next.js serves the frontend and one API route. Parts of that guide use older Studio setup (sanity.json, sanity start), so use a current Studio (defineConfig, npx sanity@latest) for the Sanity side.
1. Product schema with a Stripe price ID
import {defineType} from 'sanity'
export const product = defineType({
name: 'product',
title: 'Product',
type: 'document',
fields: [
{name: 'name', type: 'string'},
{name: 'description', type: 'text'},
{name: 'image', type: 'image', options: {hotspot: true}},
{name: 'stripePriceId', title: 'Stripe price ID', type: 'string'},
],
})The guide does it differently. It stores price (in cents) and currency in Sanity and passes them to Stripe, so no products need to be set up in the Stripe dashboard. Either approach works. A Stripe price ID keeps pricing controlled in Stripe.
2. GROQ query
*[_type == "product"]{
"id": _id,
name,
description,
"image": image.asset->url,
stripePriceId
}3. Create the Checkout Session in a route
The browser posts the cart to your route. The route re-fetches the products from Sanity and builds the line items from that data. Never trust prices or price IDs sent by the client. The guide checks the cart against fresh query results for the same reason.
// app/api/checkout/route.ts (runs on the server only)
import Stripe from 'stripe'
import {createClient} from '@sanity/client'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: 'production',
apiVersion: '2026-03-01',
useCdn: false,
})
export async function POST(req: Request) {
const {items} = await req.json() // [{id, quantity}]
const products = await sanity.fetch(
`*[_type == "product" && _id in $ids]{_id, stripePriceId}`,
{ids: items.map((i: any) => i.id)}
)
// Build line_items from `products` (not from the request body).
// See Stripe's Checkout docs for the exact line_items shape when using price IDs.
const origin = req.headers.get('origin')
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: /* built from products + quantities */ [],
success_url: `${origin}/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}`,
})
return Response.json({id: session.id, url: session.url})
}4. Webhooks and order status
Use a serverless endpoint for Stripe's webhook. Verify the signature with your Stripe webhook secret, following Stripe's docs, then write the order to the Content Lake with a write-token client:
// after verifying the Stripe event
await writeClient.createIfNotExists({
_id: `order-${session.id}`, // deterministic ID: a repeated event won't create duplicates
_type: 'order',
stripeSessionId: session.id,
status: 'paid',
})
// later updates
await writeClient.patch(`order-${session.id}`).set({status: 'fulfilled'}).commit()See createIfNotExists() and patch() for details.
Where Sanity Functions fit: Functions run on Sanity's infrastructure when something happens in Sanity. Document functions fire on document create/update/delete, and there are also scheduled and PubSub functions. Their trigger is a change in your content, not an HTTP request from Stripe, so the webhook receiver itself should be your serverless route. Once that route creates the order document, a document function filtered to _type == "order" can do follow-up work without more infrastructure, such as updating inventory or sending notifications:
// sanity.blueprint.ts
import {defineBlueprint, defineDocumentFunction} from '@sanity/blueprints'
export default defineBlueprint({
resources: [
defineDocumentFunction({
name: 'on-order',
event: {on: ['create'], filter: '_type == "order"'},
}),
],
})Inside the function, build a client from context.clientOptions, which includes a robot token, and set your own apiVersion (client in Functions). Deploy with npx sanity blueprints deploy (quickstart). Avoid writes that re-trigger the same function.
Security pitfalls
- Only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYbelongs in client code.STRIPE_SECRET_KEYstays in server env vars, as in the guide's.env.local. - Never put a Sanity write token in browser JavaScript. Browser code is public, and anyone holding a write token can delete all your data. Send frontend writes through a function or proxy that validates the input (Keeping your data safe). If a token leaks, delete it in the project's API settings.
- You can read published products without a token from a public dataset.
- Mutations through the API skip Studio validation rules (mutations docs), so validate in your routes.
Was this answer helpful?
Sanity – Build the way you think, not the way your CMS thinks
Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.