Commercetools TypeScript SDK — Headless Commerce
Procedural guide for building headless storefronts using the official Commercetools TypeScript SDK. Covers cart, checkout, and customer account operations via the "Me" endpoints (storefront) and server-side admin endpoints. For product data, categories, and search use the Frontic skill instead.
Architecture
Frontic Client → Product data, categories, search, product projections (read)
Commercetools SDK → Cart, checkout/orders, customer account, payments (write/session)
Both share the same Commercetools project. Frontic handles optimized product delivery; the Commercetools SDK handles session-bound and transactional operations.
SDK Setup
Dependencies
npm install @commercetools/platform-sdk @commercetools/ts-client
@commercetools/ts-client— HTTP client with middleware chain (auth, HTTP, logging)@commercetools/platform-sdk— Typed API builder with full request/response types
Client Builder
The SDK uses a middleware-based client builder. Choose the auth flow based on the use case:
import { ClientBuilder } from '@commercetools/ts-client'
import type { AuthMiddlewareOptions, HttpMiddlewareOptions } from '@commercetools/ts-client'
import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'
// --- Auth middleware options (choose ONE per use case) ---
// Option A: Client Credentials (server-to-server, admin operations)
const authOptions: AuthMiddlewareOptions = {
host: 'https://auth.europe-west1.gcp.commercetools.com',
projectKey: PROJECT_KEY,
credentials: { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET },
scopes: ['manage_orders:my-project', 'manage_customers:my-project'],
httpClient: fetch,
}
// Option B: Anonymous session (guest browsing & cart)
const anonymousAuthOptions: AnonymousAuthMiddlewareOptions = {
host: 'https://auth.europe-west1.gcp.commercetools.com',
projectKey: PROJECT_KEY,
credentials: { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET },
scopes: ['manage_my_orders:my-project', 'manage_my_profile:my-project'],
httpClient: fetch,
}
// Option C: Password flow (logged-in customer)
const passwordAuthOptions: PasswordAuthMiddlewareOptions = {
host: 'https://auth.europe-west1.gcp.commercetools.com',
projectKey: PROJECT_KEY,
credentials: {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
user: { username: email, password: password },
},
scopes: ['manage_my_orders:my-project', 'manage_my_profile:my-project'],
httpClient: fetch,
}
// --- HTTP middleware (always the same) ---
const httpOptions: HttpMiddlewareOptions = {
host: 'https://api.europe-west1.gcp.commercetools.com',
httpClient: fetch,
}
// --- Build client & API root ---
const ctpClient = new ClientBuilder()
.withClientCredentialsFlow(authOptions) // or .withAnonymousSessionFlow() / .withPasswordFlow()
.withHttpMiddleware(httpOptions)
.withLoggerMiddleware() // optional, useful for debugging
.build()
const apiRoot = createApiBuilderFromCtpClient(ctpClient)
.withProjectKey({ projectKey: PROJECT_KEY })
When to Use Which Auth Flow
| Flow | Builder Method | Use Case | Endpoints |
|---|---|---|---|
| Client Credentials | .withClientCredentialsFlow() | Server-side BFF routes, admin operations | /carts, /orders, /customers, /payments |
| Anonymous Session | .withAnonymousSessionFlow() | Guest browsing, anonymous carts (client-side) | /me/carts, /me/orders |
| Password | .withPasswordFlow() | Logged-in customer (client-side) | /me/carts, /me/orders, /me/login |
Common pattern: Use client credentials in your Nuxt/Next server routes (BFF) and handle anonymous/customer scoping yourself via query filters. This avoids managing per-user OAuth tokens server-side.
Regions
| Region | Auth Host | API Host |
|---|---|---|
| Europe | auth.europe-west1.gcp.commercetools.com | api.europe-west1.gcp.commercetools.com |
| US | auth.us-central1.gcp.commercetools.com | api.us-central1.gcp.commercetools.com |
| Australia | auth.australia-southeast1.gcp.commercetools.com | api.australia-southeast1.gcp.commercetools.com |
Endpoint Types
"Me" endpoints (storefront, scoped to current user/session):
/me/carts, /me/orders, /me/signup, /me/login, /me/password, /me/email/confirm
SDK: apiRoot.me().carts(), apiRoot.me().orders(), apiRoot.me().signup(), etc.
Admin endpoints (server-side, full access):
/carts, /orders, /customers, /payments, /shipping-methods
SDK: apiRoot.carts(), apiRoot.orders(), apiRoot.customers(), etc.
Use "Me" endpoints for storefront operations. Use admin endpoints for server-side order management, fulfillment, and back-office tasks.
Core Workflows
Anonymous Session → Cart → Login → Checkout
1. Build client with anonymous/client-credentials flow
2. apiRoot.carts().post({ body: cartDraft }) → Create cart
3. apiRoot.carts().withId({ ID }).post({ body: update }) → Add items
4. apiRoot.me().login().post({ body: credentials }) → Login (cart transfers)
(rebuild client with customer-scoped token)
5. apiRoot.carts().withId({ ID }).post({ body: update }) → Set addresses
6. apiRoot.carts().withId({ ID }).post({ body: update }) → Set shipping
7. apiRoot.carts().withId({ ID }).post({ body: update }) → Attach payment
8. apiRoot.me().orders().post({ body: orderDraft }) → Create order from cart
Guest Checkout (No Account)
1. Build client with anonymous/client-credentials flow
2. apiRoot.carts().post({ body: { currency, customerEmail } }) → Create cart with email
3. apiRoot.carts().withId({ ID }).post({ body: update }) → Add items
4. apiRoot.carts().withId({ ID }).post({ body: update }) → Set addresses & shipping
5. apiRoot.carts().withId({ ID }).post({ body: update }) → Attach payment
6. apiRoot.me().orders().post({ body: orderDraft }) → Create order
Anonymous carts get an anonymousId. On signup/login, resources with matching anonymousId transfer to the customer automatically.
Reference Files
- @references/cart.md: Create/update carts, line items, discount codes, addresses, shipping method, payment attachment, cart structure
- @references/checkout.md: Order creation from cart, payment flow, shipping method selection, order queries, server-side order management
- @references/account.md: Signup, login, profile updates, addresses, password change/reset, email verification, anonymous-to-customer transfer
- @references/security.md: Security audit checklist, session hardening, query injection prevention, input validation, rate limiting, safe error handling
Common Patterns
Singleton API Root (Critical)
The SDK's auth middleware caches OAuth tokens internally — but only if you reuse the same client instance. Never create a new client per request. This causes a fresh token request to Commercetools auth on every API call, adding latency and unnecessary auth traffic.
import { ClientBuilder } from '@commercetools/ts-client'
import type { AuthMiddlewareOptions, HttpMiddlewareOptions } from '@commercetools/ts-client'
import { createApiBuilderFromCtpClient } from '@commercetools/platform-sdk'
// Singleton — created once, reused across all requests
let _apiRoot: ReturnType<typeof createApiBuilderFromCtpClient> | null = null
export function getApiRoot() {
if (_apiRoot) return _apiRoot
const authOptions: AuthMiddlewareOptions = {
host: AUTH_HOST,
projectKey: PROJECT_KEY,
credentials: { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET },
scopes: SCOPES.split(' '),
httpClient: fetch,
}
const httpOptions: HttpMiddlewareOptions = {
host: API_HOST,
httpClient: fetch,
}
const client = new ClientBuilder()
.withClientCredentialsFlow(authOptions)
.withHttpMiddleware(httpOptions)
.build()
_apiRoot = createApiBuilderFromCtpClient(client)
.withProjectKey({ projectKey: PROJECT_KEY })
return _apiRoot
}
In Nuxt server routes, use useRuntimeConfig() inside the lazy initializer:
export function getApiRoot() {
if (_apiRoot) return _apiRoot
const { commercetools } = useRuntimeConfig()
// ... build client using commercetools config
_apiRoot = createApiBuilderFromCtpClient(client)
.withProjectKey({ projectKey: commercetools.projectKey })
return _apiRoot
}
### Optimistic Concurrency with Retry
Every resource has a `version` field. All updates require the current version. In production, version conflicts (409) will happen — concurrent tabs, race conditions between add-to-cart clicks. Always wrap mutations in a retry loop that re-fetches the resource on conflict:
```typescript
import type { CartUpdateAction } from '@commercetools/platform-sdk'
/**
* Execute a cart update with automatic retry on version conflicts.
* Re-fetches the cart on each retry to get the latest version.
*/
async function updateCartWithRetry(
cartId: string,
buildActions: (cart: Cart) => CartUpdateAction[],
maxRetries = 3,
): Promise<Cart> {
const apiRoot = getApiRoot()
for (let attempt = 0; attempt < maxRetries; attempt++) {
const { body: cart } = await apiRoot
.carts()
.withId({ ID: cartId })
.get()
.execute()
try {
const { body: updated } = await apiRoot
.carts()
.withId({ ID: cartId })
.post({ body: { version: cart.version, actions: buildActions(cart) } })
.execute()
return updated
} catch (error: any) {
if (error.statusCode === 409 && attempt < maxRetries - 1) continue
throw error
}
}
throw new Error('Max retries exceeded')
}
// Usage
const updatedCart = await updateCartWithRetry(cartId, () => [
{ action: 'addLineItem', sku: 'SKU-001', quantity: 1 },
])
The buildActions callback receives the freshly-fetched cart, so actions can depend on current cart state (e.g., checking existing line items before adding).
Error Handling
The SDK throws errors with the Commercetools error shape. Preserve the original status code — don't wrap everything as 500. The client needs to distinguish between a 400 (bad input), 404 (not found), and 409 (version conflict) to react appropriately.
try {
const { body } = await apiRoot.carts().withId({ ID: cartId }).get().execute()
} catch (error: any) {
// error.statusCode — HTTP status (400, 404, 409, etc.)
// error.message — Error message
// error.body.errors — Array of detailed errors with codes
if (error.statusCode === 409) {
// ConcurrentModification — re-fetch and retry (use updateCartWithRetry)
}
if (error.statusCode === 404) {
// ResourceNotFound — cart expired or was deleted
}
}
In BFF server routes, forward the SDK error status to the client:
// Good — preserves the original error for the client to handle
catch (error: any) {
throw createError({
statusCode: error.statusCode || 500,
message: error.message,
data: error.body?.errors, // forward Commercetools error details
})
}
// Bad — masks all errors as 500, client can't distinguish causes
catch (error: any) {
throw createError({
statusCode: 500,
message: error.message || 'Something went wrong',
})
}
Common error codes:
| Code | Meaning |
|---|---|
InvalidOperation | Action not allowed in current state |
ConcurrentModification | Version conflict — re-fetch and retry |
ResourceNotFound | Entity does not exist |
InvalidInput | Missing or malformed fields |
DuplicateField | Unique constraint violated (e.g., email) |
Money Type
All monetary values use the Money type with centAmount (smallest currency unit):
import type { Money } from '@commercetools/platform-sdk'
const price: Money = {
centAmount: 4999,
currencyCode: 'EUR',
}
// centAmount: 4999 with currencyCode: "EUR" = 49.99 EUR
Resource Identifiers
References use typed ResourceIdentifier:
import type { ShippingMethodResourceIdentifier } from '@commercetools/platform-sdk'
// By ID
const ref: ShippingMethodResourceIdentifier = {
typeId: 'shipping-method',
id: 'shipping-method-uuid',
}
// Or by key
const refByKey: ShippingMethodResourceIdentifier = {
typeId: 'shipping-method',
key: 'standard-shipping',
}
Querying with Where Predicates
The SDK supports Commercetools query predicates for filtering:
// Find active anonymous cart
const { body } = await apiRoot
.carts()
.get({
queryArgs: {
where: [`anonymousId="${anonymousId}"`, `cartState="Active"`],
sort: 'lastModifiedAt desc',
limit: 1,
withTotal: false,
},
})
.execute()
const cart = body.results[0] // Cart | undefined
Common Pitfalls
-
Creating a new client per request — The SDK's auth middleware caches OAuth tokens internally, but only within the same client instance. Creating a new
ClientBuilderper API call means a fresh token request every time. Use the singleton pattern (see "Singleton API Root" above). -
Forgetting
.execute()— SDK methods return request builders, not promises. Always call.execute()to send the request. The response is{ body, statusCode, headers }. -
Not retrying on 409 — Version conflicts happen in production (concurrent tabs, quick double-clicks). Always wrap mutations in a retry loop that re-fetches the resource before each attempt. See
updateCartWithRetrypattern above. -
Sending one action per request — Commercetools supports multiple actions in a single update. Batch related actions to reduce round-trips, lower version conflict risk, and ensure atomicity:
// Good — one request, one version increment const actions: CartUpdateAction[] = [ { action: 'setShippingAddress', address: shippingAddress }, { action: 'setBillingAddress', address: billingAddress }, { action: 'setCustomerEmail', email: 'guest@example.com' }, ] // Bad — three requests, three version increments, three potential conflicts await updateCart({ action: 'setShippingAddress', address: shippingAddress }) await updateCart({ action: 'setBillingAddress', address: billingAddress }) await updateCart({ action: 'setCustomerEmail', email: 'guest@example.com' }) -
Storing
cartIdinstead ofanonymousIdas primary key — Cart IDs become stale when carts expire, get merged, or transition toOrdered. TheanonymousIdis the stable identifier. Always query for the active cart byanonymousId:// Resilient — always finds the current active cart const cart = await getActiveCartByAnonymousId(anonymousId) // Fragile — cart might be expired, merged, or ordered const cart = await getCartById(cartIdFromCookie)Store
anonymousIdin a long-lived cookie. Optionally cachecartIdfor performance, but fall back to theanonymousIdquery if the cart is gone. -
Using add/remove delta instead of
changeLineItemQuantity— Don't calculate quantity differences and calladdLineItem/removeLineItemto adjust. Use the purpose-built action:// Good — one atomic action { action: 'changeLineItemQuantity', lineItemId, quantity: 5 } // Bad — fragile delta calculation, potential race condition if (newQty > oldQty) addLineItem(sku, newQty - oldQty) else removeLineItem(lineItemId, oldQty - newQty) -
Masking SDK errors as 500 — The SDK throws structured errors with
statusCode,message, andbody.errors[]. Forward the original status code to the client so it can distinguish 400 (bad input), 404 (not found), and 409 (version conflict). Don't wrap everything as 500. -
Cart state after ordering — Once
POST /me/orderssucceeds, the cart transitions tocartState: "Ordered"and is read-only. Create a new cart for subsequent shopping. -
Anonymous ID management — Generate the
anonymousIdclient-side (e.g.,crypto.randomUUID()) and persist it in a cookie (30 days). Pass it when creating carts. On login, resources with matchinganonymousIdtransfer automatically. -
Duplicate line items stack — Adding the same SKU/product twice doesn't create two line items. It increases the quantity on the existing line item.
-
SDK type imports — Import types from
@commercetools/platform-sdk, not from@commercetools/ts-client. The platform SDK has all domain types (Cart,CartDraft,CartUpdateAction,Order,Customer, etc.). -
Token scopes — Client credentials tokens can access admin endpoints (
/carts,/orders) but NOT "Me" endpoints (/me/carts). Anonymous/password tokens can access "Me" endpoints but have limited scope. Choose your auth flow accordingly.
Security
Every BFF implementation that handles Commercetools sessions, authentication, and user input must be hardened against common web security pitfalls. The full security reference is in @references/security.md — read it when implementing or auditing any server-side Commercetools integration.
Critical areas:
-
Session cookie signing — Customer session cookies must be HMAC-signed. A plain JSON cookie lets attackers forge sessions for any customer ID.
-
Query predicate injection — Values interpolated into Commercetools
whereclauses (anonymousId, email, IDs) must be validated against strict format rules (UUID regex, email regex). Without validation, attackers can inject predicate logic to access other users' data. -
Input validation — Validate all user input (email, strings, quantities, addresses) at the API boundary with length limits, type checks, and format validation. Don't rely on Commercetools to reject bad input.
-
Rate limiting — Login and register endpoints must be rate-limited by IP to prevent brute-force attacks.
-
Error sanitization — Never forward raw Commercetools error messages to the client. Map known errors to user-friendly messages and use a safe error helper for everything else.
-
Cookie security — All cookies must be
httpOnly,securein production, with appropriatesameSitesettings (strictfor auth cookies,laxfor anonymous session).
After implementing any Commercetools BFF, run through the Security Audit Checklist in @references/security.md to verify all areas are covered.