- Complete Payload CMS Ecommerce Plugin Guide — Stripe
Complete Payload CMS Ecommerce Plugin Guide — Stripe
Install and configure the @payloadcms/plugin-ecommerce: stripeAdapter, carts, orders, access control, and React hooks

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryRelated Posts:
@payloadcms/plugin-ecommerce is Payload's official ecommerce plugin. Install it, pass your access control functions, configure the stripeAdapter, and you get Products, Variants, Carts, Transactions, Orders, and Addresses wired together out of the box. The plugin handles the data model and payment initiation — you wire the frontend using the provided React hooks and add your own logic for shipping, taxes, and email on top. It is currently in Beta at version 3.80.0, so pin your version and read the changelog before upgrading.
Every few weeks someone lands in my DMs asking whether Payload has an official ecommerce plugin. For a long time the answer was "not really — you build it yourself." That changed in Q3 2025 when the Payload team shipped @payloadcms/plugin-ecommerce. I've been watching it mature, and it's now at a point where I'd reach for it on a standard Stripe-powered store without hesitation.
This guide covers everything from installation through a working checkout flow: the plugin config, the access parameter in detail, the Stripe adapter, the React provider and hooks, and an honest look at what remains on your plate. If you need a fully custom data model — non-Stripe gateway, complex multi-warehouse inventory, role-based pricing — the bespoke approach in How to Build E-commerce with Payload CMS is where to start instead.
Should You Use the Plugin or Build Custom?
Before installing anything, confirm you're on the right path. The plugin covers the 80% case well.
| Situation | Recommendation |
|---|---|
| Standard store, Stripe payments, no unusual requirements | Use the plugin |
| Non-Stripe gateway, role-based pricing, complex variant rules | Build custom |
| Mostly standard but need extra fields or custom hooks | Use plugin as base, extend with overrides |
If you're building a straightforward shop — products, a cart, Stripe checkout, order history — the plugin gets you there without wiring every collection by hand. The moment you need a payment gateway Stripe doesn't cover, or pricing logic that can't live in a per-currency price field, the custom path gives you more control.
Step 1: Installation and Project Setup
Two paths in depending on whether you're starting fresh or adding to an existing project.
Path A — Start Fresh with the Official Template
The fastest way to a working ecommerce Payload project is the official template. It ships with Stripe, auth, draft preview, search, and automated E2E tests — everything wired together. It's also in Beta, same as the plugin.
pnpx create-payload-app my-project -t ecommerce
cd my-project && cp .env.example .env
pnpm install && pnpm dev
Open http://localhost:3000, create your first admin user, and you'll land in an admin panel with all the ecommerce collections already configured. Even if you're adding the plugin to an existing project, it's worth cloning the template separately just to read through src/access/ and the frontend cart pages — they show the intended patterns for access control and the checkout flow.
Path B — Add to an Existing Payload Project
pnpm add @payloadcms/plugin-ecommerce
Then register it in your config:
// File: src/payload.config.ts
import { buildConfig } from 'payload'
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import {
adminOnlyFieldAccess,
adminOrPublishedStatus,
isAdmin,
isAuthenticated,
isCustomer,
isDocumentOwner,
} from './access'
export default buildConfig({
collections: [
// your existing collections
],
plugins: [
ecommercePlugin({
access: {
adminOnlyFieldAccess,
adminOrPublishedStatus,
isAdmin,
isAuthenticated,
isCustomer, // optional
isDocumentOwner,
},
customers: { slug: 'users' },
}),
],
})
The access parameter is the first thing that trips people up. The plugin does not ship default access functions — it requires you to provide your own. The required ones are adminOnlyFieldAccess, adminOrPublishedStatus, isAdmin, isAuthenticated, and isDocumentOwner. isCustomer is optional and used internally for address creation. Here's a minimal starting point for each:
// File: src/access/isAdmin.ts
import type { Access } from 'payload'
export const isAdmin: Access = ({ req }) => {
return req.user?.roles?.includes('admin') ?? false
}
// File: src/access/isAuthenticated.ts
import type { Access } from 'payload'
export const isAuthenticated: Access = ({ req }) => {
return Boolean(req.user)
}
// File: src/access/isDocumentOwner.ts
import type { Access } from 'payload'
export const isDocumentOwner: Access = ({ req }) => {
if (!req.user) return false
if (req.user.roles?.includes('admin')) return true
return {
customer: { equals: req.user.id },
}
}
// File: src/access/adminOnlyFieldAccess.ts
import type { FieldAccess } from 'payload'
export const adminOnlyFieldAccess: FieldAccess = ({ req }) => {
return req.user?.roles?.includes('admin') ?? false
}
// File: src/access/adminOrPublishedStatus.ts
import type { Access } from 'payload'
export const adminOrPublishedStatus: Access = ({ req }) => {
if (req.user?.roles?.includes('admin')) return true
return { _status: { equals: 'published' } }
}
After registering the plugin, run your migrations to create the new tables. The Payload CMS migration workflow for PostgreSQL covers this in detail if you're not already on a migration-first setup.
pnpm payload migrate:create --name ecommerce_plugin pnpm payload migrate
Step 2: Understanding the Collections the Plugin Creates
The plugin adds six collections. The relationships matter more than the field list, so here's the entity flow:
Customer → Cart → Transaction → Order
Cart → CartItem → Product / Variant
Customer → Address (reusable across Orders)
Products are your catalog. Each product has a price field per configured currency — the plugin creates separate price fields, one per currency, not a single multi-value field. Products also hold a join to their Variants and a set of allowed Variant Types. If you're building anything beyond a simple flat product list, the Shopify-style variant architecture for Payload CMS is worth reading before you design your product structure.
Variants are optional and attach to Products via the join field. Each variant carries its own price and can be assigned Variant Options from the parent product's Variant Types. A T-shirt with Size (S, M, L) and Color (Black, White) would have one Variant Type per attribute, each with the relevant options, and separate Variant documents for each combination.
Carts are created automatically the first time a customer adds a product. The plugin supports both authenticated users (cart linked to a customer field) and guests (cart stored by ID in localStorage, customer is null). The plugin registers these custom cart endpoints automatically:
POST /api/carts/:cartID/add-itemPOST /api/carts/:cartID/update-itemPOST /api/carts/:cartID/remove-itemPOST /api/carts/:cartID/clear
You generally won't call these directly — the useCart hook manages them — but they're there if you need server-side cart manipulation.
Transactions are created when initiatePayment runs. They start at status "Processing" and stay there if the customer abandons checkout. When payment completes and confirmOrder succeeds, the Transaction moves to "succeeded". Only admins can access Transactions — they're an internal payment audit trail, not something customers query directly.
Orders are created only after a Transaction reaches "succeeded". An Order is the permanent record of what was purchased, by whom, and at what price at the time of purchase. Customers and admins can both read Orders. Guest users get secure access via an accessToken generated at checkout and sent in their confirmation email.
Addresses belong to Customers and are reusable across Orders. On checkout, the customer picks a saved address or creates a new one, which the plugin stores for next time.
Step 3: Wiring Up Stripe
The plugin uses an adapter pattern. stripeAdapter handles the server-side payment logic — creating PaymentIntents, registering webhook handlers, managing Transactions — and stripeAdapterClient handles the client-side Stripe.js integration. You configure both in their respective places and the plugin registers the REST endpoints automatically.
Install the Stripe SDK
pnpm add stripe
The plugin is developed and tested against Stripe SDK 18.3.0. Use at least that version.
Environment Variables
# File: .env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOKS_SIGNING_SECRET=whsec_...
Configure the Stripe Adapter
// File: src/payload.config.ts
import { buildConfig } from 'payload'
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
export default buildConfig({
plugins: [
ecommercePlugin({
access: { /* your access functions */ },
customers: { slug: 'users' },
payments: {
paymentMethods: [
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!, // optional but recommended
}),
],
},
}),
],
})
The import path is @payloadcms/plugin-ecommerce/payments/stripe for the server-side adapter. The webhookSecret is optional for the core payment flow — initiatePayment and confirmOrder work without it — but you'll want it as soon as you need custom event handling.
Handling Stripe Events
If you need to react to specific Stripe events, register handlers inside stripeAdapter rather than building a separate webhook route. The plugin registers them within Payload's REST API automatically:
// File: src/payload.config.ts
stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY!,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOKS_SIGNING_SECRET!,
webhooks: {
'payment_intent.payment_failed': async ({ event, req }) => {
const paymentIntent = event.data.object
await req.payload.update({
collection: 'transactions',
where: { stripePaymentIntentID: { equals: paymentIntent.id } },
data: { status: 'failed' },
req,
})
},
},
})
The req object inside webhook handlers is the full Payload request, so you have access to req.payload for database operations.
Active Beta bug — confirmOrder does not verify PaymentIntent status before creating an Order. If a payment fails or is cancelled (for example via a Stripe-hosted PayPal payment the user declines), confirmOrder still creates an Order and marks the cart as purchased. PR #15902 is in progress. Until it lands, register a payment_intent.payment_failed webhook handler like the one above, and check Transaction status on your order confirmation page before showing the success UI to the customer.
Active Beta bug — initiatePayment hardcodes cart.subtotal as the PaymentIntent amount. Shipping costs and discounts are not factored in. PR #15954 adds a resolveAmount override. Until it lands, compute the full order total yourself before calling initiatePayment and handle any delta (shipping, discount) upstream in your checkout logic.
Step 4: The Frontend — Provider, Cart, and Checkout
The plugin ships a React provider and hooks. You wrap your app with EcommerceProvider, then use useCart for cart operations and usePayments to initiate and finalize checkout.
Wrap Your App with EcommerceProvider
// File: src/app/layout.tsx
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<EcommerceProvider
api="/api"
cartsSlug="carts"
customersSlug="users"
addressesSlug="addresses"
paymentMethods={[
stripeAdapterClient({
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
}),
]}
currenciesConfig={{
defaultCurrency: 'USD',
supportedCurrencies: [
{ code: 'USD', decimals: 2, label: 'US Dollar', symbol: '$' },
],
}}
>
{children}
</EcommerceProvider>
</body>
</html>
)
}
The client-side adapter comes from @payloadcms/plugin-ecommerce/payments/stripe as well — it's a separate named export, stripeAdapterClient, not the same as the server-side stripeAdapter.
Adding Products to the Cart
// File: src/components/AddToCartButton.tsx
'use client'
import { useCart } from '@payloadcms/plugin-ecommerce/client/react'
type Props = {
productId: string
variantId?: string
}
export function AddToCartButton({ productId, variantId }: Props) {
const { addItem, isLoading } = useCart()
return (
<button
disabled={isLoading}
onClick={() =>
addItem({
product: productId,
...(variantId && { variant: variantId }),
})
}
>
{isLoading ? 'Adding...' : 'Add to Cart'}
</button>
)
}
useCart also exposes removeItem, incrementItem, decrementItem, clearCart, and refreshCart. One behaviour to know upfront: adding the same product and variant combination increments quantity rather than creating a separate line item. If you need multiple distinct line items for the same SKU, you'll need to manage cart state manually using refreshCart after direct API calls — this was a confirmed pain point in the GitHub issues.
Displaying Cart Items
// File: src/components/Cart.tsx
'use client'
import { useCart } from '@payloadcms/plugin-ecommerce/client/react'
export function Cart() {
const { cart, removeItem, isLoading } = useCart()
if (!cart?.items?.length) return <p>Your cart is empty.</p>
return (
<ul>
{cart.items.map((item: any) => (
<li key={item.id}>
<span>{item.product?.title ?? 'Product'}</span>
<span> × {item.quantity}</span>
<button onClick={() => removeItem({ itemID: item.id })}>
Remove
</button>
</li>
))}
</ul>
)
}
Guest Cart Behaviour
For unauthenticated users, the plugin stores the cart ID in localStorage and sends it on each cart request. There is no built-in "claim cart" endpoint. When the user logs in, you associate the guest cart with their account by updating cart.customer via the standard Payload REST API, then clearing the localStorage reference:
// File: src/lib/claimCart.ts
export async function claimCartAfterLogin(cartId: string, userId: string) {
await fetch(`/api/carts/${cartId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer: userId }),
})
localStorage.removeItem('cartId')
}
Call this in your post-login callback before redirecting the user.
Checkout — initiatePayment and confirmOrder
// File: src/components/CheckoutFlow.tsx
'use client'
import { usePayments, useCart } from '@payloadcms/plugin-ecommerce/client/react'
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'
export function CheckoutFlow() {
const { cart } = useCart()
const { initiatePayment, confirmOrder, isLoading } = usePayments()
const stripe = useStripe()
const elements = useElements()
async function handleCheckout() {
if (!cart?.id || !stripe || !elements) return
// Step 1 — plugin calls /api/payments/stripe/initiate-payment,
// creates a Transaction (status: "Processing"), returns clientSecret
const { clientSecret } = await initiatePayment({
cartID: cart.id,
email: 'customer@example.com', // collect from your checkout form
})
// Step 2 — confirm payment on the client with Stripe.js
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/order-confirmation`,
},
redirect: 'if_required',
})
if (error) {
console.error(error.message)
return
}
// Step 3 — plugin calls /api/payments/stripe/confirm-order,
// retrieves the PaymentIntent from Stripe, creates the Order
await confirmOrder({ paymentIntentID: paymentIntent?.id })
}
return (
<div>
<PaymentElement />
<button disabled={isLoading} onClick={handleCheckout}>
{isLoading ? 'Processing...' : 'Pay Now'}
</button>
</div>
)
}
Both /api/payments/stripe/initiate-payment and /api/payments/stripe/confirm-order are registered automatically by the plugin — you don't build these endpoints yourself. initiatePayment creates the Transaction in Payload and returns the Stripe clientSecret. confirmOrder retrieves the PaymentIntent from Stripe and creates the Order document. Your job is calling them at the right point in the UI flow.
Step 5: The Gaps — What You Still Need to Build
The plugin handles the data model and the payment adapter. These areas are out of scope and need manual implementation:
Shipping. There is no shipping concept in the plugin. The practical approach is to add a shippingMethod relationship field to the Orders collection via collectionsOverrides, calculate shipping cost in a hook on the cart or order, and factor it into the PaymentIntent amount yourself before calling initiatePayment. Until the resolveAmount override lands (PR #15954), the total is cart.subtotal only. The safe hook patterns guide for Payload and PostgreSQL covers transactional consistency if you're writing to multiple collections in one operation.
Taxes. Not included. The standard approach is to calculate tax based on the shipping address before initiatePayment runs and include it in the total. Stripe Tax can automate the calculation if you're already in the Stripe ecosystem.
Subscriptions. Stripe Billing is not wired to the plugin. Subscriptions need a separate subscriptions collection, their own webhook handlers for customer.subscription.created and invoice.payment_succeeded, and linkage back to your Users collection. Treat this as a parallel implementation on top of the plugin's infrastructure rather than an extension of it.
Order confirmation emails. The plugin creates the Order document but sends no emails. Add an afterChange hook to the Orders collection that fires when status transitions to "succeeded" and dispatches a transactional email from there. The Payload CMS email notifications guide covers the full setup including the native email plugin and the common afterChange hook pitfalls to avoid.
FAQ
Does @payloadcms/plugin-ecommerce work with MongoDB or only PostgreSQL?
It works with any database adapter Payload supports. The plugin uses Payload's standard collection API — no raw SQL. The migration commands in Step 1 are specific to @payloadcms/db-postgres. For MongoDB you skip the migration step entirely.
Can I override the collections the plugin creates?
Yes. The plugin accepts a collectionsOverrides option where you can add fields, change access control, or attach hooks to any generated collection. The plugin merges your overrides with its defaults, so you're extending rather than replacing the entire collection definition.
The plugin is at version 3.80.0 and Payload core is also 3.x — are they the same version?
Yes. The plugin lives in the Payload monorepo and versions in lockstep with the core package. If you're on payload@3.80.0, use @payloadcms/plugin-ecommerce@3.80.0. Mismatched versions are a common source of type errors.
How do I add multiple currency support?
Configure currencies.supportedCurrencies in ecommercePlugin() and ensure the same currencies are enabled in your Stripe account. The plugin creates a separate price field per currency on Products and Variants. There is an active Beta bug where useCurrency() may only return USD regardless of config (GitHub issue #14541) — verify it's resolved in your installed version before building multi-currency UI.
What Stripe SDK version should I install?
The plugin targets Stripe SDK 18.3.0. Use at least that version: pnpm add stripe@^18.3.0.
Conclusion
@payloadcms/plugin-ecommerce gives you a complete ecommerce data model — Products, Variants, Carts, Transactions, Orders, Addresses — plus a Stripe adapter, auto-registered payment endpoints, and a React provider with useCart and usePayments hooks. The setup covered here goes from zero to a working checkout flow: plugin config with the correct access functions, Stripe adapter with the right import paths and config shape, EcommerceProvider wrapping your app, and initiatePayment/confirmOrder handling the full payment lifecycle.
The active Beta bugs around confirmOrder status verification and the hardcoded subtotal amount are real — both have PRs in progress and should land soon. Pin @payloadcms/plugin-ecommerce to an exact version in package.json and review the changelog before every upgrade.
When your requirements outgrow what the plugin covers — custom payment gateway, complex pricing rules, inventory management — the next step is building the data model yourself. The full walkthrough is in How to Build E-commerce with Payload CMS.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija
📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.


