Everything *[NYC] 2026: see what we announced. →
Skip to content

Can I build an ecommerce store with Sanity and Stripe without a separate backend?

0repliesLast updated: Nov 7, 2026

I want to build a small store with Sanity as the product catalog and Stripe for payments. Do I need to run a separate backend server, or can I do it with just Sanity and my frontend framework? Which parts have to run server-side?
AI Update

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:

Must run server-side (a route handler or serverless function):

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

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.

Related contributions