@sixtyninecommerce/client
JavaScript/TypeScript client for sixtynine-commerce storefronts.
Install
npm install @sixtyninecommerce/clientQuick Start
import { createStorefrontClient } from '@sixtyninecommerce/client';
const client = await createStorefrontClient({
storeCode: 'my-store',
cdnUrl: 'https://cdn.my-store.com',
// Optional: retry transient GETs (default { attempts: 2, baseDelayMs: 200 })
retry: { attempts: 3, baseDelayMs: 200 },
// Optional: notified when the customer's auth token is rejected
onAuthExpired: () => router.push('/sign-in'),
});
// Products
const product = await client.commerce.getProduct('blue-shirt');
const results = await client.commerce.searchProducts({ term: 'shirt' });
// Collections (hierarchical taxonomies, auto-synced to facet values)
// Filter by a collection and get descendants automatically via the
// FacetValue bridge (`coll--{channelCode}--{id}`) on the server:
const byCollection = await client.commerce.searchProducts({
collectionSlug: 'wines',
});
// Canonical + hreflang for <head> tags
const product = await client.commerce.getProduct('chateau-x');
const { canonical, alternates } = client.seo.metaFor({
kind: 'product',
slug: product.slug,
translations: product.translations,
collections: product.collections,
});
// Full structured head metadata, JSON-LD, robots, hreflang merge.
const tags = client.seo.metaTagsFor({
kind: 'product',
product: {
slug: product.slug,
translations: product.translations,
collections: product.collections,
},
metadata: product.metadata ?? null,
extras: {
name: product.name,
description: product.description,
sku: product.variants[0]?.sku,
price: { amount: '29.99', currency: 'EUR' },
availability: 'https://schema.org/InStock',
featuredAsset: product.featuredAsset,
},
});
// tags.title / tags.description / tags.canonical / tags.robots
// tags.openGraph: [{ property, content }]
// tags.twitter: [{ name, content }]
// tags.hreflang: [{ rel, hreflang, href }]
// tags.jsonLd: [{ '@type': 'Product' }, { '@type': 'BreadcrumbList' }, ...]
// Per-bot AI directives — render in <head> and attach as X-Robots-Tag headers.
const bots = client.seo.robotsDirectiveFor({
kind: 'product',
metadata: product.metadata ?? null,
});
// bots.metaTags: [{ name: 'GPTBot', content: 'noindex, nofollow' }, ...]
// bots.xRobotsHeaders: [{ name: 'X-Robots-Tag', value: 'GPTBot: noindex, nofollow' }, ...]
// AI summary + llms.txt rendering for AI agents
const summary = client.seo.aiSummaryFor({ kind: 'product', metadata: product.metadata ?? null });
const llmsLine = client.seo.llmsTxtEntryFor({
kind: 'product',
product: { slug: product.slug, collections: product.collections },
metadata: product.metadata ?? null,
url: tags.canonical,
name: product.name,
description: product.description,
});
// Cart
await client.commerce.addToCart(product.variants[0].id, 1);
const order = await client.commerce.getActiveOrder();
// Customer subscriptions, VAT validation, MyParcel shipping
const subs = await client.commerce.getMySubscriptions();
const vat = await client.commerce.getMyVatValidationStatus();
const dropOffs = await client.commerce.getDropOffPoints({ postalCode: '1011AB' });
const tracking = await client.commerce.trackShipment('3SXXXXXX', '1011AB');
// CMS content
const page = await client.content.resolvePath('/about-us');
const menu = await client.content.getMenu('main-menu');
const sitemap = await client.content.getSitemap('en');
// Resolve CMS reference fields (image[], content_item[], etc.)
const images = await client.content.lookupAssets(item.values.gallery);
const linked = await client.content.lookupContentItems(item.values.related_posts);
// Routes
const routes = await client.navigation.getRoutes();
// Cancel a slow read (typeahead, page navigation, SSR)
const controller = new AbortController();
const results = await client.commerce.searchProducts(
{ term: 'shoes' },
{ signal: controller.signal }
);
// AbortSignal is supported on read methods (catalog, content, navigation, sitemap)
// Mutations (cart, checkout, customer auth) intentionally don't accept signal.
// Pricing helpers
client.pricing.formatPrice(2999, 'EUR'); // "€29.99"
client.pricing.getTierForQuantity(variant.priceTiers, 10);
client.pricing.validateOrderMinimums(order, { minOrderAmount: 5000 });
// Media helpers
client.media.assetUrl('shop/source/photo.jpg');
client.media.getPresetUrl(asset, 'm', 'webp');
client.media.getResponsiveImageData(asset); // { src, srcSet, sizes, alt }
// Language & auth
client.setLanguage('nl');
client.setAuthToken(token);
// Escape hatch for custom queries
const data = await client.graphql(`query { activeChannel { id } }`);Modules
| Module | Description |
|---|---|
commerce |
Products, collections, property trees, search, cart, checkout, customer auth |
content |
CMS resolve, content items, assets, forms, content strings, menus |
navigation |
Unified route manifest, route lookup |
pricing |
Price formatting, tier lookup, order validation (pure functions) |
media |
CDN URLs, preset URLs, responsive image data (pure functions) |
currency |
Exchange rates, currency conversion |
Passwordless (Magic Link) Login
Customers can sign in without a password. requestMagicLink emails a one-tap
link and a 6-digit code; consuming either starts a session. It coexists
with password login (login) — both are available. For unknown emails the server
creates a verified account on first sign-in (when the channel enables signup).
// 1. Request — always resolves (anti-enumeration). Show "check your inbox" regardless.
await client.commerce.requestMagicLink(email);
// 2a. Consume the LINK (token from the emailed URL's ?token= param)
const user = await client.commerce.loginWithMagicLink(token);
// 2b. …or consume the 6-digit CODE (cross-device: typed on the same tab that requested it)
const user = await client.commerce.loginWithMagicCode(email, code);
// Either path starts a session and fires onAuthToken — no extra token handling needed.Errors thrown (via MutationError.errorCode): MAGIC_LINK_INVALID_ERROR,
MAGIC_LINK_EXPIRED_ERROR, MAGIC_LINK_LOCKED_ERROR (too many wrong codes).
Reference UI (storefront)
Two pages. Request page — collect the email, then offer the code field:
function MagicLinkRequest() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'sent'>('idle');
const [code, setCode] = useState('');
async function request(e: React.FormEvent) {
e.preventDefault();
await client.commerce.requestMagicLink(email.trim()); // never reveals existence
setStatus('sent'); // always advance — even if no account exists
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
try {
await client.commerce.loginWithMagicCode(email.trim(), code.trim());
window.location.assign('/account');
} catch {
/* show "invalid or expired code — request a new link" */
}
}
if (status === 'sent') {
return (
<form onSubmit={submitCode}>
<p>If an account exists for <b>{email}</b>, we’ve emailed a sign-in link and code.</p>
{/* npx shadcn add input-otp */}
<input inputMode="numeric" maxLength={6} value={code} onChange={(e) => setCode(e.target.value)} />
<button type="submit">Sign in</button>
</form>
);
}
return (
<form onSubmit={request}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<button type="submit">Email me a sign-in link</button>
</form>
);
}Callback page (/sign-in/magic?token=…) — do not auto-consume on load. Require
a click. Email-security scanners pre-fetch links (which would burn the single-use
token), and React StrictMode double-mounts effects. A confirm button avoids both:
function MagicLinkCallback() {
const token = new URLSearchParams(location.search).get('token');
const [state, setState] = useState<'confirm' | 'working' | 'invalid'>('confirm');
if (!token) return <p>Invalid sign-in link.</p>;
async function confirm() {
setState('working');
try {
await client.commerce.loginWithMagicLink(token!);
window.location.assign('/account'); // session token already captured via onAuthToken
} catch {
setState('invalid');
}
}
if (state === 'invalid') return <p>This link expired or was already used. <a href="/sign-in/magic">Request a new one</a>.</p>;
return <button disabled={state === 'working'} onClick={confirm}>Sign in</button>;
}Cross-device note: opening the link on a phone signs in the phone. To sign in the desktop tab that started checkout, enter the 6-digit code there instead.
Price Lists & Volume Pricing
Every variant fragment returned by commerce.getProduct, searchProducts, getCollection, getMyFavorites, and getProductRecommendations already includes the price-list metadata:
| Field | Meaning |
|---|---|
price / priceWithTax |
The actual price the visitor will pay (already discounted) |
retailPrice / retailPriceWithTax |
The undiscounted list price (use for <s> strikethrough) |
priceListName |
Human label of the winning price list (e.g. "Wholesale", "Buy 5+ deal") |
priceTiers |
[{ minQuantity, price, priceWithTax, discountLabel }] — full ladder for upsells |
Three audiences see populated values:
- Logged-in customers with a customer-direct or group assignment
- Logged-in customers in a channel that has a price list with
appliesToAllCustomers = true - Anonymous (guest) visitors in a channel that has a price list with
appliesToAllCustomers = true
Resolution priority is contract → customer-direct → group → channel-wide → retail (first match wins, not lowest price).
// Render a "Buy 5 for €1,249" upsell on the product page
const product = await client.commerce.getProduct(slug);
const variant = product.variants[0];
const nextTier = client.pricing.getTierForQuantity(variant.priceTiers ?? [], 5);
if (nextTier) {
// nextTier.priceWithTax · nextTier.minQuantity · nextTier.discountLabel
}Product Documents
commerce.getProduct(slug) and commerce.getProductById(id) return a documents: PublicProductDocument[] field with downloadable files attached to the product (manuals, spec sheets, data sheets, certificates). The list is already filtered by the current customer's visibility before it leaves the server — the storefront just renders what it gets.
const product = await client.commerce.getProduct('drill-pro-2000');
for (const doc of product.documents ?? []) {
// <a href={doc.fileUrl} download={doc.fileName}>{doc.title}</a>
// {doc.description}
// {doc.fileMimeType} · {formatFileSize(doc.fileSize)}
}| Field | Notes |
|---|---|
title, description |
Resolved for the active language; fall back to the channel default |
fileUrl |
Already absolute. Public docs use the channel CDN URL; non-public docs are short-lived presigned R2 URLs (default 15min). Don't pass through any media helper |
fileMimeType, fileSize, fileName |
Use for the meta line and the download attribute |
requiresAuth |
true when the URL is presigned. Storefront pages with any requiresAuth: true doc must not be cached publicly — set Cache-Control: private, no-store on those responses |
position |
Display order — render in this order |
Two language modes are transparent to the storefront — the resolver picks the right row before responding:
- Universal: one file used regardless of the active language (covers English-only docs and multi-lingual single PDFs).
- Localized: per-language title/description with optional per-locale file. Falls back to channel default, then to any sibling translation that has a file.
Visibility is gated server-side: PUBLIC (everyone), AUTHENTICATED (logged-in only), CUSTOMER_GROUP (allowlist of customer groups). Anonymous visitors only ever see PUBLIC docs.
Tree-Shakeable Imports
// Import only pricing (no network code)
import { formatPrice, getTierForQuantity } from '@sixtyninecommerce/client/pricing';
// Import only media helpers
import { PRESETS, getResponsiveImageData } from '@sixtyninecommerce/client/media';Error Handling
Cart and checkout mutations throw MutationError with the Vendure error code:
import { MutationError } from '@sixtyninecommerce/client';
try {
await client.commerce.addToCart(variantId, 100);
} catch (error) {
if (error instanceof MutationError) {
// error.errorCode — "INSUFFICIENT_STOCK_ERROR"
// error.message — "Only 3 items available"
// error.fields — { quantityAvailable: 3 }
}
}Requirements
- Node.js 20+ / modern browser / edge runtime
- Standard
fetchAPI (inject custom fetch via config for SSR)