7.7 KiB
| tags | topic | sources | created | updated | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
payloadcms |
|
2026-05-15 | 2026-05-15 |
PayloadCMS — Ecommerce
Overview
@payloadcms/plugin-ecommerce adds full-featured store functionality on top of Payload. Currently Beta — expect breaking changes.
Collections created automatically:
products+variants,variantTypes,variantOptionscarts(guest and authenticated)orders,transactionsaddresses
Not covered natively: shipping, taxes, subscriptions — implement yourself via hooks.
Setup
pnpm add @payloadcms/plugin-ecommerce stripe
Minimal config — you must provide all access-control functions yourself:
import { ecommercePlugin } from '@payloadcms/plugin-ecommerce'
import { stripeAdapter } from '@payloadcms/plugin-ecommerce/payments/stripe'
export default buildConfig({
plugins: [
ecommercePlugin({
access: {
adminOnlyFieldAccess: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
adminOrPublishedStatus: ({ req: { user } }) =>
user?.roles?.includes('admin') ? true : { _status: { equals: 'published' } },
isAdmin: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
isAuthenticated: ({ req: { user } }) => Boolean(user),
isCustomer: ({ req: { user } }) => Boolean(user && !user?.roles?.includes('admin')),
isDocumentOwner: ({ req: { user } }) =>
user?.roles?.includes('admin') ? true : user?.id ? { customer: { equals: user.id } } : false,
},
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!,
}),
],
},
}),
],
})
Key Patterns
Stripe Payment Flow
- Frontend calls
initiatePayment→ server creates a Stripe PaymentIntent + Transaction (status: "Processing"), returnsclient_secret - Frontend confirms payment via Stripe.js using
client_secret - Frontend calls
confirmOrder→ server verifies intent, creates Order, marks Transaction as "succeeded", setscart.purchasedAt
REST endpoints added automatically:
POST /api/payments/stripe/initiatePOST /api/payments/stripe/confirm-orderPOST /api/payments/stripe/webhooks
Stripe Webhooks (local dev)
stripe login
stripe listen --forward-to localhost:3000/api/payments/stripe/webhooks
# Copy the printed whsec_... secret → STRIPE_WEBHOOKS_SIGNING_SECRET in .env
stripe trigger payment_intent.succeeded # test
The CLI webhook secret is ephemeral — changes on every stripe listen restart. Update .env and restart dev server.
Cart API Endpoints
| Endpoint | Description |
|---|---|
POST /api/carts/:id/add-item |
Add product/variant, auto-increments if match |
POST /api/carts/:id/update-item |
Set qty or { $inc: n } operator |
POST /api/carts/:id/remove-item |
Remove by itemID |
POST /api/carts/:id/clear |
Empty cart |
Server-side equivalents: addItem, updateItem, removeItem, clearCart from @payloadcms/plugin-ecommerce.
Multi-currency
Prices stored as integers (avoids float issues). USD 1000 = $10.00.
currencies: {
supportedCurrencies: [USD, { code: 'JPY', decimals: 0, label: 'Japanese Yen', symbol: '¥' }],
defaultCurrency: 'USD',
}
Adding a currency triggers a schema migration (new price fields on products/variants).
Config / Code Examples
Frontend Provider
import { EcommerceProvider } from '@payloadcms/plugin-ecommerce/client/react'
import { stripeAdapterClient } from '@payloadcms/plugin-ecommerce/payments/stripe'
import { USD, EUR } from '@payloadcms/plugin-ecommerce'
<EcommerceProvider
currenciesConfig={{ supportedCurrencies: [USD, EUR], defaultCurrency: 'USD' }}
paymentMethods={[stripeAdapterClient({ publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! })]}
>
{children}
</EcommerceProvider>
Available React hooks:
useCart— add/remove/update items,isLoadingstateusePayments—initiatePayment,confirmOrderuseAddresses— create/update addressesuseCurrency—formatPrice(amount),setCurrencyuseEcommerce— all of the above unified +onLogin,onLogout,clearSession,mergeCart,refreshCart
Session management
// After login — merges guest cart into user cart automatically
await onLogin()
// After logout — clears cart, addresses, localStorage
onLogout() // or clearSession()
Custom CartItemMatcher (e.g. fulfillment options)
const fulfillmentMatcher: CartItemMatcher = ({ existingItem, newItem }) => {
const productMatches = existingItem.product === newItem.product
const variantMatches = newItem.variant ? existingItem.variant === newItem.variant : !existingItem.variant
return productMatches && variantMatches && existingItem.fulfillment === newItem.fulfillment
}
Piecemeal usage (collections only)
import { createProductsCollection, createCartsCollection } from 'payload-plugin-ecommerce'
// Add individual collections without the full plugin
Available factory functions: createAddressesCollection, createCartsCollection, createOrdersCollection, createTransactionsCollection, createProductsCollection, createVariantsCollection, createVariantTypesCollection, createVariantOptionsCollection.
Custom ProductsValidation
Override validation called before transaction creation and payment confirmation:
products: {
validation: ({ product, variant, quantity, currency }) => {
if (!currency) throw new Error('Currency required')
const priceField = `priceIn${currency.toUpperCase()}`
if (!(variant ?? product)?.[priceField]) throw new Error('No price in currency')
// add custom checks: max qty, region lock, etc.
}
}
Translations
The plugin ships admin UI translations under the plugin-ecommerce namespace. Import and register them explicitly:
import { en } from '@payloadcms/translations/languages/en'
import { enTranslations as ecommerceEn } from '@payloadcms/plugin-ecommerce/translations/languages/en'
buildConfig({
i18n: {
supportedLanguages: { en },
translations: { en: ecommerceEn },
},
})
Override specific strings by spreading and patching the namespace:
translations: {
en: {
...ecommerceEn,
'plugin-ecommerce': {
...ecommerceEn['plugin-ecommerce'],
cart: 'Shopping Cart',
orders: 'My Orders',
},
},
}
Gotchas
publicAccessis the only default — all other access functions must be implemented by you- Adding a currency = schema migration — new price columns/fields on products and variants
- Guest cart secret is ephemeral — stored in localStorage; lost on browser clear
stripeSDK not auto-installed — runpnpm add stripeseparately (min18.5.0)- Never trust client-side prices — always compute total server-side from DB records
- Webhook secret from
stripe listenchanges on restart — update.enveach time customerOnlyFieldAccessis deprecated — useisCustomerinstead (removed in v4)confirmOrdercreates the Order — notinitiatePayment; abandoned checkouts leave Transaction in "Processing"
Related
- wiki/payloadcms/plugins — plugin system overview and how to build your own
- wiki/payloadcms/database — migrations, transactions, Postgres vs MongoDB
- wiki/payloadcms/access-control — access function patterns