@doswiftly/storefront-sdk
v24.5.0
Published
Storefront runtime SDK for DoSwiftly Commerce — layered transport, middleware pipeline, React providers, Zustand stores, cache strategies. 0 runtime dependencies in core.
Maintainers
Readme
@doswiftly/storefront-sdk
Layered runtime SDK for DoSwiftly Commerce storefronts. Framework-agnostic core + React adapter, 0 runtime dependencies in core.
Architecture
@doswiftly/storefront-sdk
├── core (.) — Framework-agnostic: transport + middleware pipeline,
│ createStorefrontClient / createCustomerClient,
│ CartClient / AuthClient, cart recovery runner,
│ cart capability cookie (id + secret), auth route helpers,
│ bot-protection managers, errors, format utilities,
│ sanitizeHtml, normalizeConnection, schema enums
├── react (./react) — React adapter: StorefrontProvider, CartManagerProvider,
│ CustomerClientProvider, Zustand stores (Context-based),
│ auth/cart/session hooks, pre-built headless components
├── react/server — Server-side: client factory, SDK-BFF auth route
│ (createStorefrontAuthRoute), getInitialAuth,
│ first-party cookie readers, server cart-secret middleware
└── cache (./cache) — Cache strategy functionsCore works everywhere: Node.js, Edge Workers, Deno, Bun, CLI scripts — without React.
React adapter requires react ^18 || ^19 and zustand ^5 as peer dependencies.
Installation
pnpm add @doswiftly/storefront-sdkConfiguration
The framework-agnostic core (createStorefrontClient, createCustomerClient) takes apiUrl and shopSlug as explicit config — it never reads environment variables, sniffs hostnames, or inspects request headers. The storefront supplies the values; the client uses them verbatim.
The React providers (<StorefrontProvider>, <StorefrontClientProvider>, <CustomerClientProvider>) add one convenience on top: config is optional. When apiUrl / shopSlug are omitted they fall back to the NEXT_PUBLIC_API_URL / NEXT_PUBLIC_SHOP_SLUG env vars, so a standard Next.js deploy is zero-config. An explicit config value always wins over the environment, and a clear error is thrown at mount if neither source supplies a value.
Scaffolded storefronts (doswiftly init) ship a graphqlConfig helper (lib/graphql/config.ts) that resolves both values from three sources in order:
| Source | When | What it gives you |
| --- | --- | --- |
| doswiftly.config.ts (preferred) | doswiftly init storefronts | A committed config file with both values. No env wiring needed in normal use. |
| NEXT_PUBLIC_API_URL + NEXT_PUBLIC_SHOP_SLUG (fallback) | Local development; storefronts scaffolded outside doswiftly init | Standard Next.js public env vars. doswiftly dev rewrites NEXT_PUBLIC_API_URL to a local CORS proxy at runtime so client-side calls don't hit production CORS headers. |
| http://localhost:8000 + demo-shop (defaults) | Empty smoke test | Last-resort placeholders so the project boots before any config is written. |
If you go the env-var route, use exactly these names — doswiftly dev keys off them when overriding. Inventing API_URL, STOREFRONT_URL, or TENANT_SLUG means the dev proxy starts, but your storefront still calls the production API directly and you only learn about it on the first client-side mutation (build and SSR pass silently).
Scratch-built storefronts can read process.env.NEXT_PUBLIC_* directly and pass the values into config={} — the resolution helper is a convenience, not a requirement.
Visitor telemetry
<StorefrontProvider> includes lightweight, cookieless visitor telemetry, enabled by default. On page load it sends a single ping and, while the tab is visible, a periodic heartbeat that powers "traffic" and "visitors online" figures in your shop dashboard.
- What is collected: the page path, the referrer, the browser user agent, and an ephemeral, in-memory session id (a random value that lives only for the visit and is regenerated on the next load).
- No cookies, no local storage, no PII. Nothing is persisted in the browser, and the SDK sends no personal data. Traffic source and device are classified server-side from the raw referrer and user agent — the SDK ships the raw values only.
- Well-behaved by design: browser-only (never runs during SSR), fire-and-forget (a failed send never affects the page), pauses while the tab is hidden, and stops sending if the endpoint reports the shop is unknown.
Opt out, or tune the heartbeat cadence, on the client config:
// Disable entirely
<StorefrontProvider config={{ telemetry: false }} shopData={shopData}>…</StorefrontProvider>
// Keep it on but change the heartbeat interval (ms)
<StorefrontProvider config={{ telemetry: { heartbeatIntervalMs: 60000 } }} shopData={shopData}>…</StorefrontProvider>The heartbeat interval has a minimum of 15 seconds; smaller values are raised to that floor.
Quick start — Next.js App Router
Four files give you a production-grade storefront runtime: first-party auth cookies with automatic session refresh, a shared cart with stale-cart auto-recovery, and a typed GraphQL client with the full middleware pipeline.
1. app/api/auth/[action]/route.ts — one file mounts the whole auth surface
(POST /api/auth/login | signup | refresh | logout, GET /api/auth/whoami). The
handlers run on the storefront's own domain, call the backend server-to-server, and
own the first-party httpOnly cookies — the refresh token never reaches browser
JavaScript. signup is symmetric to login: it both creates the account and
establishes the durable session in one call.
import {
createStorefrontAuthRoute,
trustedForwardedHostValidator,
} from '@doswiftly/storefront-sdk/react/server';
export const { GET, POST } = createStorefrontAuthRoute({
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
// Pass when the storefront runs behind a reverse proxy that rewrites Host
// (DoSwiftly hosting, Vercel). Omit for bare deployments / local dev.
isTrustedOrigin: trustedForwardedHostValidator,
});2. app/layout.tsx — seed the first render from the first-party cookies via
getInitialAuth() (no signed-out flash, no whoami round-trip) and wrap the tree
in StorefrontProvider:
import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
import { getStorefrontClient, getInitialAuth } from '@doswiftly/storefront-sdk/react/server';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const serverClient = getStorefrontClient({
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
});
// `SHOP_CONFIG_QUERY` is your own operation — generated by graphql-codegen from
// `@doswiftly/storefront-operations/schema.graphql`. It must request the fields
// of the `ShopConfig` shape: `currencyCode`, `supportedCurrencies`,
// `defaultLanguage`, `supportedLanguages`, `botProtection`.
const [{ shop }, initialAuth] = await Promise.all([
serverClient.query(SHOP_CONFIG_QUERY),
getInitialAuth(),
]);
return (
<html lang="pl">
<body>
<StorefrontProvider
config={{
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
}}
shopData={shop}
initialIsAuthenticated={initialAuth.isAuthenticated}
initialAccessToken={initialAuth.accessToken}
initialExpiresAt={initialAuth.expiresAt}
>
{children}
</StorefrontProvider>
</body>
</html>
);
}Session refresh is automatic — the provider defaults to autoRefresh in the
browser: a scheduler renews the access token shortly before it expires, and a 401
on a read query triggers a single deduped refresh + replay. Pass
autoRefresh={false} to drive refreshing yourself.
3. Cart — wrap the shopping subtree in CartManagerProvider (one shared cart
manager: one loading state, one recovery queue) and read it with
useCartManagerContext():
'use client';
import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
import { toast } from 'sonner';
export function ShopProviders({ children }: { children: React.ReactNode }) {
return (
<CartManagerProvider
onMutationError={(operation, error) => toast.error(error.message)}
>
{children}
</CartManagerProvider>
);
}
export function AddToCart({ variantId }: { variantId: string }) {
const { addItem, status } = useCartManagerContext();
return (
<button
onClick={() => addItem([{ variantId, quantity: 1 }])}
disabled={status.type === 'loading'}
>
Add to cart
</button>
);
}The cart cookie, the cart access secret, creation on first add, and stale-cart recovery are all handled for you — see Cart.
4. Global session + cart expiry handling — mount once near the root:
'use client';
import { useEffect } from 'react';
import { useSessionExpired, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
export function GlobalGuards() {
const router = useRouter();
const { onExpired } = useCartManagerContext();
// Fired when the SDK can no longer keep the customer session alive.
useSessionExpired(() => router.replace('/auth/login?reason=session_expired'));
// Fired when a stale cart cannot be transparently recovered.
useEffect(
() => onExpired(() => toast.error('Your cart expired — please add the items again')),
[onExpired],
);
return null;
}Quick start — Core (framework-agnostic)
import {
createStorefrontClient,
cartSecretMiddleware,
retryMiddleware,
timeoutMiddleware,
errorMiddleware,
CartClient,
formatCartCookieValue,
} from '@doswiftly/storefront-sdk';
let cartSecret: string | null = null;
const client = createStorefrontClient({
apiUrl: 'https://api.doswiftly.pl',
shopSlug: 'my-shop',
middleware: [
cartSecretMiddleware(() => cartSecret), // lazy getter; secret rides cart ops only
retryMiddleware({ maxRetries: 2 }),
timeoutMiddleware({ timeout: 5000 }),
errorMiddleware(), // ALWAYS LAST
],
});
const cartClient = new CartClient(client);
// `create` reveals a one-time cart access secret — store it immediately.
// Possession of the secret is what authorizes cart reads and writes.
const { cart, secret } = await cartClient.create();
cartSecret = secret;
if (secret) {
persistCookie('cart-id', formatCartCookieValue({ cartId: cart.id, cartSecret: secret }));
}
const { cart: updated, warnings } = await cartClient.addItems(cart.id, [
{ variantId: 'variant-123', quantity: 1 },
]);Queries are deduplicated and cacheable; mutations are never cached and never retried.
Export paths
| Path | Description | Dependencies |
|------|-------------|-------------|
| @doswiftly/storefront-sdk | Core: transport, middleware, clients, recovery, errors, format, enums, cookie contracts | 0 |
| @doswiftly/storefront-sdk/react | Providers, hooks, stores, pre-built UI components | react, zustand |
| @doswiftly/storefront-sdk/react/server | Server client factory, SDK-BFF auth route, cookie readers | react (peer; getInitialAuth additionally requires Next.js) |
| @doswiftly/storefront-sdk/next | Zero-config Next.js adapters — createImageLoader() for images.loaderFile | 0 |
| @doswiftly/storefront-sdk/next/config | getAssetPrefix() for next.config — loads under Node's native module resolver | 0 |
| @doswiftly/storefront-sdk/cache | Cache strategy functions | 0 |
Authentication
Session model
Auth runs through BFF route handlers on the storefront's own domain
(createStorefrontAuthRoute). The browser never talks to the backend directly
for auth — the route handlers do, server-to-server. First-party cookies work
identically on a platform subdomain, a custom domain, and off-platform hosting.
| Cookie | httpOnly | Path | Purpose |
|--------|----------|------|---------|
| customerAccessToken | yes | / | Access token — read server-side to seed SSR and the in-memory store |
| customerRefreshToken | yes | /api/auth | Refresh token — read exclusively server-side by the BFF route |
| session-expiry | no | / | Readable absolute expiry (ISO 8601) for the refresh scheduler |
Renewal is automatic (<StorefrontProvider autoRefresh> — default ON in the
browser):
- Proactive — a scheduler calls
POST {authBasePath}/refreshshortly beforeexpiresAt; the route rotates the refresh cookie and returns a fresh access token. - Reactive — a 401 on a read query triggers one deduped refresh and replays
the query. A 401 on a mutation never retries — it fires the
session-expiredsignal instead (replaying a mutation, e.g. a payment, would be unsafe).
When the session cannot be kept alive, subscribe globally:
'use client';
import { useSessionExpired } from '@doswiftly/storefront-sdk/react';
useSessionExpired((event) => router.replace('/auth/login'));Sign-in form (BFF login) — useBffLogin
useBffLogin posts to POST {authBasePath}/login — the only flow that sets the
full cookie set (access + refresh + expiry) on the storefront domain, required
for automatic session refresh. It seeds the client-side store and returns
{ success, userErrors }, symmetric to useRegister — no hand-rolled fetch:
'use client';
import { useBffLogin } from '@doswiftly/storefront-sdk/react';
export function LoginForm() {
const { login, isLoggingIn, error } = useBffLogin();
async function onSubmit(email: string, password: string) {
const result = await login(email, password);
if (!result.success) {
// Backend errors are passed through verbatim (already localized).
showErrors(result.userErrors);
return;
}
// Signed in — the store is seeded and the first-party cookies are set.
}
// ...
}useBffLogin mirrors useRegister: same { success, userErrors } shape, same
store seeding. (Login is not bot-protected on the backend, so unlike useRegister
it fetches no verification challenge.) login accepts an optional loginPath via
useBffLogin({ loginPath }) when the route is mounted off the default /api/auth.
After a successful sign-in, merge the guest cart into the customer's cart with
cartClient.merge(guestCartId) — see Auth ↔ cart lifecycle.
GraphQL auth hooks — useLogin / useLogout / useAuth
The focused hooks drive auth over the GraphQL transport
(customerLogin / customerLogout mutations) and keep the token in the
in-memory store. The backend sets/clears its own httpOnly cookie on these
mutations, on the API domain — fine for same-site setups and non-browser
clients. For first-party cookies on the storefront domain (and the refresh
cookie required by autoRefresh) prefer useBffLogin above; the optional
onSetToken / onClearToken callbacks let you sync a cookie through your own
route during migration from older setups.
import { useLogin, useLogout } from '@doswiftly/storefront-sdk/react';
const { login, isLoggingIn, error } = useLogin();
const { logout, isLoggingOut } = useLogout();
const result = await login(email, password);
if (!result.success) showErrors(result.userErrors); // backend-translated messagesuseLogout additionally downgrades the active cart to guest before logging out
(clears the customer's contact details, addresses and saved-payment selection so
they do not linger on a shared device) — best-effort, never blocks sign-out.
Registration with auto sign-in — useRegister
useRegister posts to the BFF signup route, so a new account gets the same
durable first-party session as login (access and refresh cookies) in a single
call — no separate sign-in step. The new access token is seeded into the store, so
the buyer is signed in on the next render. Bot protection is automatic — when the shop
has it configured, the hook fetches a verification token from the same manager that
protects GraphQL mutations and forwards it; you never handle tokens yourself.
import { useRegister } from '@doswiftly/storefront-sdk/react';
const { register, isRegistering, error } = useRegister();
const result = await register({
// Beyond email + password every field is optional — including the newsletter opt-in.
email,
password,
firstName,
lastName,
acceptsMarketing: true,
});
if (!result.success) showErrors(result.userErrors); // backend-translated messagesThe registration input is a CustomerCreateInput. Beyond email + password every
field is optional, including the newsletter opt-in: acceptsMarketing (a true
subscribes the customer; omit it to leave consent unchanged) and marketingOptInLevel
(SINGLE_OPT_IN by default, or CONFIRMED_OPT_IN to send a double-opt-in confirmation
email and leave consent pending until the buyer confirms).
Mount the signup action with createStorefrontAuthRoute (the Next.js App Router quick start above) — the same route file already serves it.
useAuth(options?) is a convenience facade aggregating useLogin, useLogout
and useRefreshToken:
const {
login, logout, refreshToken,
isLoggingIn, isLoggingOut, isRefreshingToken, isLoading,
error,
} = useAuth();Auth state (customer, flags) lives in the store, not in the hooks:
import { useAuthStore, useAuthHydrated } from '@doswiftly/storefront-sdk/react';
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const customer = useAuthStore((s) => s.customer);
const authHydrated = useAuthHydrated(); // true after persist rehydrationE-mail verification — useEmailVerification
Every account e-mail (welcome / verification) carries a confirmation link. By
default the link lands on a built-in platform confirmation page — your storefront
needs no code for verification to work. To host your own branded page instead,
build it with this hook, then in the admin panel turn off the Built-in e-mail
verification page switch (Settings → Store → Customer account) and enter your
page's path; from then on e-mails link to your page with the raw token in the
?token= query param.
'use client';
import { useEmailVerification } from '@doswiftly/storefront-sdk/react';
const { verifyEmail, resendVerificationEmail, isVerifying, isResending } = useEmailVerification();
// Verification page (e.g. /account/verify-email?token=…) — public, no session needed:
const result = await verifyEmail(token);
if (result.success) showSuccess(); // idempotent — safe on refresh
else if (result.error) showRetryNotice(); // request failed — supply your own copy
else if (result.userErrors[0]?.code === 'TOKEN_EXPIRED') offerResend();
// Account settings CTA for a signed-in customer:
const resend = await resendVerificationEmail(); // recipient comes from the session
if (!resend.success && resend.userErrors[0]?.code === 'ALREADY_VERIFIED') showVerifiedBadge();Both actions resolve with { success, userErrors, error } (no throw on backend
rejections). Stable userErrors[].code values: TOKEN_EXPIRED, TOKEN_INVALID,
TOKEN_USED for verifyEmail; ALREADY_VERIFIED, TOKEN_INVALID (no session)
for the resend — their message is already localised, render it as-is.
A failed request (rate limit, network, server error) arrives as error, never as a
userErrors entry: the backend authors no copy for it, so the SDK invents none.
Supply your own wording and pick it with error.code, error.isNetworkError or
error.retryAfterMs. After a successful verification refetch the customer to pick
up the updated isEmailVerified field.
Cold-start gating — wait for the session to settle
On a hard reload the short-lived access cookie may have lapsed while the
long-lived session-expiry hint still marks the buyer authenticated. The refresh
scheduler recovers the token immediately — via the idempotent whoami route (no
refresh-token rotation, not rate-limited), falling back to a rotating refresh only
if the access token is actually gone — but a customer-account query that fires in
that window races ahead with no token and gets a guest response — showing a
logged-in buyer as "signed out".
Gate customer-account reads on useAuthReady() so the first read waits for the
cold-start refresh:
import { useAuthReady } from '@doswiftly/storefront-sdk/react';
const authReady = useAuthReady(); // false only while authenticated-but-no-token-yet
const { data } = useQuery({
queryKey: ['account'],
queryFn: fetchAccount,
enabled: authReady, // don't fetch as a guest mid-refresh
});useAuthReady() is true for a guest or an authenticated buyer with a token in
memory, and false only while the identity is settling (useAuthSettling() is the
inverse). Combine with useAuthHydrated() to also wait for localStorage
rehydration.
AuthClient (no React)
import { AuthClient } from '@doswiftly/storefront-sdk';
const authClient = new AuthClient(client, { authBasePath: '/api/auth' });| Method | Returns | Notes |
|---|---|---|
| login(email, password) | Promise<AuthResult> | GraphQL mutation; throws StorefrontError with backend-translated userErrors |
| register(input) | Promise<AuthResult> | CustomerCreateInput; result carries customer |
| logout() | Promise<void> | Never throws (token may already be expired) |
| refreshSession() | Promise<SessionRefreshResult> | Same-origin POST {authBasePath}/refresh (BFF) — works with an expired access token; throws SESSION_EXPIRED on failure, or RATE_LIMITED on a 429 (transient — retry, not a logout) |
| recoverSession() | Promise<SessionRecoverResult \| null> | Same-origin GET {authBasePath}/whoami (BFF) — recovers the in-memory access token without rotating the refresh token (idempotent, not rate-limited). null when there is no live session (fall back to refreshSession()) |
| getCustomer() | Promise<Customer \| null> | null when unauthenticated |
| getAddresses() | Promise<MailingAddress[] \| null> | Saved address book incl. B2B fields (taxId, vatNumber) and isDefault; null when unauthenticated |
| verifyEmail(token) | Promise<void> | Consumes the raw token from the verification e-mail URL; public (no session needed), idempotent after success. Throws StorefrontError — read userErrors[0].code (TOKEN_EXPIRED / TOKEN_INVALID / TOKEN_USED) |
| resendVerificationEmail() | Promise<void> | Queues a fresh verification e-mail for the signed-in customer (no arguments — recipient from session). Throws StorefrontError with ALREADY_VERIFIED when the address is already confirmed |
| refreshToken() | Promise<AuthResult> | Deprecated — GraphQL refresh requires a still-valid access token; use refreshSession() |
Low-level route handlers (escape hatch)
createStorefrontAuthRoute is the recommended mount. The standalone Web API
factories remain available for custom setups and migrations:
import {
createSetTokenHandler, // POST — sets the httpOnly access-token cookie
createClearTokenHandler, // POST — clears it
createWhoamiHandler, // GET — hydrates { isAuthenticated, customer } from the cookie
} from '@doswiftly/storefront-sdk';
export const POST = createSetTokenHandler();All handlers are pure Web API (Request/Response) — they run in Next.js Route
Handlers, Cloudflare Workers, Deno, etc. Security baked in: strict origin
validation, Content-Type check, SameSite=Lax, httpOnly cookies.
Behind a reverse proxy
When a proxy rewrites/strips Host (DoSwiftly hosting, Vercel, NGINX), strict
Origin host === Host validation would 403 every auth call. Every handler (and
createStorefrontAuthRoute) accepts an isTrustedOrigin predicate:
import {
trustedForwardedHostValidator, // trust X-Forwarded-Host (set by the proxy)
originAllowlistValidator, // static allowlist of origins
} from '@doswiftly/storefront-sdk';
createStorefrontAuthRoute({ apiUrl, shopSlug, isTrustedOrigin: trustedForwardedHostValidator });
// or
createSetTokenHandler({
isTrustedOrigin: originAllowlistValidator(['https://shop.example.com']),
});Custom predicates get ({ origin, originHost, request }) and may be async. A
throwing predicate fails closed (falls back to strict matching).
Auth token client (client-side helper)
import { createAuthTokenClient } from '@doswiftly/storefront-sdk';
const { setToken, clearToken } = createAuthTokenClient();
await setToken(accessToken); // POST /api/auth/set-token
await clearToken(); // POST /api/auth/clear-tokenNewsletter
useNewsletter() powers a subscribe / unsubscribe widget. No customer session is
required, so it works in a footer form for anonymous visitors. Subscribing is
double opt-in: a confirmation e-mail goes out and the address joins the list only
after the recipient clicks the link.
'use client';
import { useNewsletter } from '@doswiftly/storefront-sdk/react';
export function NewsletterForm() {
const { subscribe, isSubscribing } = useNewsletter();
const [notice, setNotice] = useState('');
async function onSubmit(email: string) {
const { accepted, userErrors, error } = await subscribe(email);
if (accepted) return setNotice('Check your inbox to confirm.');
if (error) return setNotice('Could not send it right now — please try again.');
setNotice(userErrors[0]?.message ?? 'Please check the address.');
}
return /* your form */;
}Reading the response:
| Field | Meaning |
|---|---|
| accepted: true | The request was taken. It does not mean the address is on the list — an address that already exists returns the same response, so the endpoint cannot be used to probe the list. Show one neutral confirmation. |
| userErrors | The submitted address was rejected. Each entry carries a stable code (INVALID_EMAIL_FORMAT, TOO_LONG) and a message already localised by the backend — render it as-is, branch on the code. One address can produce more than one entry, so scan the array. |
| error | The request never reached a verdict: rate limit, bot-protection rejection, network failure, server error. The backend authors no buyer-facing copy for these, so the SDK invents none — supply your own and pick it with error.code, error.isNetworkError or error.retryAfterMs. |
The hook never throws — a rejected address is ordinary form feedback, and a failed
request is reported through error. unsubscribe(email) follows the same
contract. Marketing e-mails also carry a one-click unsubscribe link, so a
dedicated unsubscribe page is optional.
The opt-in level is not a parameter — guest subscriptions always use double opt-in and the server ignores any value sent for it.
Both mutations are rate limited to 10 requests per minute. When the shop has bot
protection configured, StorefrontProvider attaches the token automatically —
that is also why the hook is the supported surface: it inherits the provider's
middleware pipeline (bot protection, error normalisation) without extra wiring.
Customer account surface
The GraphQL API has two surfaces:
| Surface | Endpoint | Use for | Caching |
|---|---|---|---|
| Public catalog | /storefront/graphql | Products, collections, search, cart, checkout | Cacheable (shared-edge eligible) |
| Customer account | /storefront/customer/graphql | The signed-in buyer's orders, addresses, wishlist, loyalty | Always served fresh (never shared-cached) |
StorefrontProvider and createStorefrontClient target the public surface by
default. To talk to the customer-account surface, use a dedicated client — it
carries the buyer's token and never serves a shared-cached response.
React — <CustomerClientProvider> + useCustomerClient()
Render <CustomerClientProvider> inside <StorefrontProvider> (it reads the same
auth, currency and language stores from context) and read the client with
useCustomerClient():
'use client';
import { CustomerClientProvider, useCustomerClient } from '@doswiftly/storefront-sdk/react';
// Wrap the signed-in area once — e.g. app/(account)/layout.tsx.
// `config` is optional: apiUrl/shopSlug default from NEXT_PUBLIC_API_URL /
// NEXT_PUBLIC_SHOP_SLUG. Pass config={{ apiUrl, shopSlug }} to override the env.
export function AccountProviders({ children }: { children: React.ReactNode }) {
return <CustomerClientProvider>{children}</CustomerClientProvider>;
}
// Anywhere below it
function OrderList() {
const client = useCustomerClient();
// client.query(...) → /storefront/customer/graphql, with the buyer's bearer token
}The provider builds a second client (the public one from StorefrontProvider is
untouched) with the full pipeline:
session-retry → auth → currency → language → retry → timeout → errors.
config.graphqlPath defaults to /storefront/customer/graphql; override it only
if you have a custom mount.
Because the account surface carries the buyer's token, it is the one most likely
to see a 401. When automatic session refresh is on (the default), it shares the
same deduped renewal as the public client — a 401 renews the session and
replays the request, and two surfaces hitting a 401 at once make a single
same-origin /refresh (one cookie rotation, no race).
Core — createCustomerClient (no React)
For Node, Edge Workers or any non-React runtime, createCustomerClient is the
framework-agnostic factory. It is createStorefrontClient with graphqlPath
defaulted to the customer-account surface — supply your own auth middleware:
import { createCustomerClient, authMiddleware } from '@doswiftly/storefront-sdk';
const customerClient = createCustomerClient({
apiUrl: 'https://api.doswiftly.pl',
shopSlug: 'my-shop',
middleware: [authMiddleware(() => getToken())],
});
const { customer } = await customerClient.query(MY_ORDERS_QUERY);Data fetching
Reading data in a Client Component? Two options — pick per need. Both run on the same
typed client.query, so the SDK never locks you into a data library.
Zero-config — useStorefrontQuery / useCustomerQuery
Drop-in hooks with data / error / loading flags, an enabled gate, refetch,
request cancellation, and re-keying (a previous result is never shown for new inputs).
'use client';
import { useStorefrontQuery, useCustomerQuery } from '@doswiftly/storefront-sdk/react';
// Catalog read (search box, "load more", client-side filters)
const { data, isFetching } = useStorefrontQuery(SearchProductsQuery, {
variables: { query }, enabled: query.length > 1,
});
// Account read — waits for the session (useAuthReady) and resets on account switch.
// Use `isLoading` (not `isPending`) for a spinner: a gated guest query keeps `isPending`
// true forever, but `isLoading` is false, so you render the empty state instead.
const { data, isLoading, error } = useCustomerQuery(RecentOrdersQuery, {
variables: { first: 5 },
});| Field | Meaning |
|---|---|
| data | Result for the current inputs, or undefined while pending / on error. |
| error | A StorefrontError, or null. |
| isPending | true until the current inputs first settle — including while gated (enabled: false). Use isLoading for a spinner. |
| isFetching | true while a request is in flight (initial load or refetch). |
| isLoading | true while a request is in flight and there's no data yet (isPending && isFetching) — the spinner gate. false when the query is gated, so a signed-out account view shows its empty state, not an endless spinner. |
| isError / isSuccess | Terminal state for the current inputs. |
| refetch() | Re-run the query. Stable reference (including through a library adapter). |
useStorefrontQuery options: variables, enabled, cache (public catalog only),
client (defaults to the public client), resetKey (extra reset axis).
useCustomerQuery targets the customer-account surface, gates on the session, and
re-keys on the signed-in customer — so it takes only variables and enabled.
These hooks keep per-component state only — no cross-component cache, focus refetch, or devtools. To add those, register a server-state library on the provider (below); the same hooks then read through it.
Browser-side cache — register a server-state library
Account data (orders, profile) isn't edge-cached, so a browser-side cache is where a
returning visitor avoids a re-fetch. Register your library once (a memoized value or a
module-level singleton, not toggled at runtime) on <StorefrontProvider queryAdapter={…}>.
The data hooks then read through it — keyed per customer, gated on the session, and cleared
on sign-out / session expiry / account switch automatically:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StorefrontProvider } from '@doswiftly/storefront-sdk/react';
import { createTanstackQueryAdapter } from '@doswiftly/storefront-sdk/react/tanstack';
const queryClient = new QueryClient();
export function Providers({ children, shopData }) {
return (
<QueryClientProvider client={queryClient}>
<StorefrontProvider shopData={shopData} queryAdapter={createTanstackQueryAdapter(queryClient)}>
{children}
</StorefrontProvider>
</QueryClientProvider>
);
}// The SAME hook — now cached cross-component, keyed per customer, cleared on sign-out.
const { data, isLoading } = useCustomerQuery(RecentOrdersQuery, { variables: { first: 20 } });@tanstack/react-query is an optional peer dependency (install it only if you use this
adapter). For advanced library features the adapter doesn't surface (select, infinite,
optimistic, …), call your library directly with useCustomerClient() + client.query.
Bring another library (SWR, urql) by implementing the StorefrontQueryAdapter port yourself.
See the storefront Data fetching guide for the full pattern.
Cart
Capability model — cart id + secret
Cart access is authorized by possession of a secret, not by the customer
session. The cart-id cookie stores a composite value "<cartId>.<secret>"
(30 days, SSR/edge-visible, not httpOnly — the cart carries no payment data).
CartClient.create() and recoveryRedeem() reveal the secret once; the SDK
persists it into the cookie for you. Cart operations then carry the secret in
the x-cart-secret header via middleware — public reads (product listings,
search, recommendations, payment methods) do not, so they stay eligible for
the shared cache even when the visitor has a cart:
- Browser:
StorefrontProviderwirescartSecretMiddlewareautomatically — the secret is read lazily from the cookie and attached only to cart operations, so a rotated secret is picked up without rebuilding the client. - Server (SSR/edge): prepend
serverCartSecretMiddleware(await readCartCredentials())to your server client — see Server-side. - Custom runtimes:
cartSecretMiddleware(() => secret)+ theparseCartCookieValue/formatCartCookieValuehelpers.
Cart operations are recognised by a Cart operation-name prefix
(isCartScopedOperation). If you issue custom cart queries, name them with a
Cart prefix, or pass your own classifier as the second argument:
cartSecretMiddleware(getSecret, (operationName) => ...).
A cookie without the secret half (or a stale capability) makes the cart
unreachable — mutations reject with CART_NOT_FOUND and the standard recovery
flow recreates a fresh cart.
useCartManager — cookie-driven cart + checkout lifecycle
The primary React cart API. Owns the cart-id cookie, auto-creates the cart on
first add, persists the secret, and recovers from stale carts per operation:
'use client';
import { useCartManager } from '@doswiftly/storefront-sdk/react';
const {
// Read
getCart, getCartId,
// Mutations (all return Promise<CartMutationOutcome> = { cart, warnings })
addItem, updateItem, removeItem,
updateBuyerIdentity, setShippingAddress, setBillingAddress,
updateDiscountCodes, updateNote, updateAttributes,
selectShippingMethod, selectPaymentMethod, clearPaymentSelection,
applyGiftCard, removeGiftCard, updateGiftCardRecipient,
// Completion
complete, // Promise<CartCompleteOutcome> = { order, warnings }
createPayment, // Promise<PaymentSession> — post-completion, works on orderId
// Lifecycle
clearCart, onExpired,
// Reactive state
status, // tagged union — see below
isLoading, error, // derived selectors over `status`
} = useCartManager(options?);Per-operation recovery strategy — when a write hits a stale cart
(userErrors[].code ∈ CART_NOT_FOUND / ALREADY_COMPLETED):
| Strategy | Operations | Behaviour |
|---|---|---|
| Auto-replay | addItem, updateBuyerIdentity, setShippingAddress, updateDiscountCodes, updateNote, updateAttributes | Transparently recreates the cart via an atomic cartCreate(input) and resolves as success |
| Bail + event | updateItem, removeItem, setBillingAddress, selectShippingMethod, selectPaymentMethod, clearPaymentSelection, applyGiftCard, removeGiftCard, updateGiftCardRecipient, complete | Clears the cookie, throws CartRecoveryNotPossibleError, and fires every onExpired listener — subscribe once globally instead of try/catching every call site |
| Out of scope | createPayment | Operates on orderId (post-completion) — no cart to recover |
Status is a tagged union for exhaustive rendering:
const { status } = useCartManager();
if (status.type === 'loading') return <Spinner label={status.operation} />;
if (status.type === 'error') return <ErrorBanner error={status.error} />;
// status.type ∈ { 'idle', 'success' }Options (UseCartManagerOptions) — all additive:
| Option | Purpose |
|---|---|
| initialCartId | Server-known cart-id seed used when the cookie is empty on mount (cookie wins). Accepts a bare id or the composite "<cartId>.<secret>" value — pass the composite when the secret is known server-side. Use cases: SSR checkout, magic-link, embedded iframe, customer-service "view this cart", multi-cart B2B. |
| onMutationStart / onMutationSuccess / onMutationError | Lifecycle callbacks around every operation — centralize toasts / router refresh / loading indicators. Cart expiry goes to onExpired, not onMutationError. |
| cookieDebug | Debug sink for cart-id cookie writes — see Debug logging. |
<CartManagerProvider> — one shared instance
useCartManager keeps per-mount state, so calling it in several components
creates independent managers. Wrap the subtree (inside StorefrontProvider) and
read the shared instance with useCartManagerContext():
'use client';
import { CartManagerProvider, useCartManagerContext } from '@doswiftly/storefront-sdk/react';
<CartManagerProvider
initialCartId={initialCartId}
onMutationSuccess={() => router.refresh()}
onMutationError={(operation, error) => toast.error(error.message)}
>
<CheckoutForm />
</CartManagerProvider>;
// CheckoutForm.tsx
const { addItem, complete, status } = useCartManagerContext();For deliberately independent managers (multi-cart B2B, an admin "view this cart"
panel) call useCartManager() directly instead.
Checkout completion + payment
const { complete, createPayment } = useCartManagerContext();
// 1. Finalize the cart into an Order. On success the cart-id cookie is cleared
// and status resets to idle — a follow-up addItem creates a fresh cart.
const { order } = await complete();
// 2. Decide the payment flow from the Order itself — no hardcoded brand checks.
if (order.canCreatePayment) {
const session = await createPayment({
orderId: order.id,
returnUrl: `${origin}/checkout/return`, // optional — must point to a verified shop domain
});
// Branch on session.flow: redirectUrl (redirect), clientSecret (embedded
// widget), status (instant settlement). Failures throw with
// `err.userErrors[0].code` of the PAYMENT_* family.
}getBrowserDataForPayment() (from /react) is a standalone helper collecting
the browser context fields used by strong-customer-authentication flows
(user agent, screen, timezone, language); it throws
BrowserDataNotAvailableError outside the browser — call it in an event
handler, never during SSR.
The completed order also carries order.accessToken — an opaque token for
guest order lookup via cartClient.getOrderByToken(token, email?).
Discovery queries (CartClient)
const cartClient = new CartClient(client);
// Cart-aware shipping preview for an address. Returns null when the cart is gone.
const payload = await cartClient.getAvailableShippingMethods(cartId, address);
if (payload) {
if (payload.userErrors.length > 0) {
// Backend business condition with a translated message
// (e.g. a digital-only cart needs no shipping).
show(payload.userErrors[0].message);
} else {
render(payload.methods, payload.freeShippingProgress);
// each method carries deliveryType: HOME | PICKUP_POINT | LOCKER
}
}
// Shop-level payment methods + the default pre-selection signal.
const { methods, defaultMethod } = await cartClient.getAvailablePaymentMethods();
// Validate a discount code before applying it.
const result = await cartClient.validateDiscountCode(cartId, 'SAVE10');
if (!result.isValid) show(result.error?.message); // backend-translated
// Guest order lookup by the opaque token from complete().
const order = await cartClient.getOrderByToken(token, email);Error-handling contract across the SDK (messages are always
backend-translated per Accept-Language — the SDK never synthesizes copy):
| Backend shape | SDK behaviour | You handle |
|---|---|---|
| Mutation with userErrors[] | Throws StorefrontError; err.userErrors[0].message is translated | try/catch, branch on err.userErrors[0].code |
| Nullable query root | Returns T \| null | if (!result) … |
| Structured payload (errors inside) | Returns the raw payload | Branch on payload.userErrors[].code / payload.error.code |
Auth ↔ cart lifecycle
// After a successful sign-in: merge the guest cart into the customer context.
// The secret is preserved — the same cookie keeps working.
await cartClient.merge(guestCartId);
// On sign-out: strip customer PII from the cart (contact details, addresses,
// saved-payment selection). `useLogout` calls this automatically.
await cartClient.downgradeOnLogout(cartId);
// Cart recovery links (e.g. from an abandoned-cart email): redeem the token.
// Rotates the secret — persist the new composite cookie value.
const { cart, secret } = await cartClient.recoveryRedeem(token);Cart recovery without React
The same recovery semantics ship in core for Vue/Svelte/CLI/mobile consumers:
import {
CartClient,
createCartRecoveryRunner,
recreateWithInput,
CartRecoveryNotPossibleError,
type CartCookieStore,
} from '@doswiftly/storefront-sdk';
// Implement the cookie port for your runtime
// (browsers can use createBrowserCartCookieStore from /react).
const cookieStore: CartCookieStore = {
get: () => readCartCookie(), // returns the cart id
set: (cartId, opts) => writeCartCookie(cartId, opts?.secret),
clear: () => deleteCartCookie(),
};
const runner = createCartRecoveryRunner({ cartClient, cookieStore });
runner.onExpired((event) => {
console.warn(`Cart expired (${event.reason}) — reset local state.`);
});
// Auto-replay: the caller never thinks about stale carts.
const { cart, warnings } = await runner.execute({
name: 'addItems',
run: (cartId) => cartClient.addItems(cartId, [{ variantId: 'v-123', quantity: 1 }]),
recreateAndRun: recreateWithInput({ lines: [{ variantId: 'v-123', quantity: 1 }] }),
});
// Bail-on-stale: no recreateAndRun — throws CartRecoveryNotPossibleError instead.Detection inspects err.userErrors[].code (CART_NOT_FOUND /
ALREADY_COMPLETED) — locale-independent. The runner also creates the cart on
first use (deduped across concurrent calls) and persists the revealed secret
through the cookie store.
useCart(cartId) — server-driven cart
Sister of useCartManager bound to an explicit cartId prop — never touches
the cookie, no auto-recovery. For SSR-rendered checkout, deep-link order
recovery, and admin "view this cart" UIs:
'use client';
import { useCart } from '@doswiftly/storefront-sdk/react';
const {
cart, isLoading, error, operation,
refetch,
addItems, updateItems, removeItems,
updateBuyerIdentity, setShippingAddress,
updateDiscountCodes, updateNote, updateAttributes,
} = useCart(cartId, {
autoFetch: false, // skip the mount fetch when the server already rendered the cart
initialCart, // SSR seed — combine with autoFetch: false
});Referral program
When the shop's loyalty referral program is enabled, customers share links like
https://shop.example/register?ref=REF-AB12CD34 (the code and share URL come
from the referralStats / loyaltyGenerateReferralCode GraphQL operations).
The SDK bridges the gap between landing on such a link and the actual signup:
// 1. Capture the code on landing — mount once near the top of the app.
'use client';
import { useReferralCapture } from '@doswiftly/storefront-sdk/react';
export function ReferralCapture() {
useReferralCapture(); // reads ?ref=... and stores it in the `referral-code` cookie (30 days)
return null;
}// 2. At signup, pass the stored code in the customerSignup input and clear it on success.
import { getReferralCodeCookie, clearReferralCodeCookie } from '@doswiftly/storefront-sdk/react';
const referralCode = getReferralCodeCookie();
await signup({ email, password, firstName, lastName, referralCode: referralCode ?? undefined });
clearReferralCodeCookie();Codes are case-insensitive and an invalid, malformed, expired or own code is
silently ignored by the API — the signup always succeeds. The capture step only
persists values matching the platform code shape (letters, digits, dashes —
REFERRAL_CODE_PATTERN), so junk ?ref= values never reach the cookie. Server
Components can read the stored code with the async readReferralCodeCookie()
from @doswiftly/storefront-sdk/react/server (e.g. to pre-fill an SSR-rendered
form) — note the deliberate naming split: synchronous getReferralCodeCookie()
on the client entry vs server-first readReferralCodeCookie() on the server
entry. Non-React runtimes can use the core helpers directly:
extractReferralCodeFromUrl(url) plus the REFERRAL_COOKIE_NAME /
REFERRAL_COOKIE_MAX_AGE constants.
The cookie lifetime (30 days, override via captureReferralCode({ maxAge }))
is the landing → signup window. It is independent of the shop's referral
validity setting — that one starts at signup, limits the time the referred
customer has to place their first order, and is enforced by the backend.
Pre-built React components
Headless, accessibility-aware, zero styling — pass className to integrate with
your CSS approach. Available from @doswiftly/storefront-sdk/react:
| Component | Purpose |
|-----------|---------|
| <Money amount currency locale?> | Locale-formatted price string from minor units |
| <Image data sizes priority> | <img> with thumbhash blur placeholder + sane defaults |
| <CartCount count label> | Aria-live cart item count |
| <AddToCartButton variantId quantity> | Button wired to useCartManager().addItem (loading state + a11y error surfacing) |
| <PriceDisplay price compareAtPrice currency locale?> | Price + optional strikethrough sale price |
| <CartTotals subtotal tax shipping discount total currency> | Cart financial breakdown <dl> |
| <PaymentInstrumentTile instrument> | One selectable payment instrument (card brand, wallet, bank) |
| <PaymentInstrumentSection method> | Instrument group for a payment method (renders tiles) |
import { Money, PriceDisplay, CartCount } from '@doswiftly/storefront-sdk/react';
<Money amount={9990} currency="PLN" locale="pl-PL" /> {/* "99,90 zł" */}
<PriceDisplay price={7990} compareAtPrice={9990} currency="PLN" locale="pl-PL" />
<CartCount count={3} label="items" />Images — zero-config next/image loader
createImageLoader() is a drop-in loader for Next.js images.loaderFile. It
routes every <Image> through the image CDN so each one is resized to the
viewport (real responsive srcset) and format-negotiated (AVIF/WebP from the
browser Accept header) — no per-image configuration.
Create the loader file (zero arguments):
// lib/image-loader.ts
import { createImageLoader } from '@doswiftly/storefront-sdk/next';
export default createImageLoader();Point Next.js at it, and bound the generated sizes so a fixed set of widths is produced (fewer transforms, better cache hit rate):
// next.config.ts
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './lib/image-loader.ts',
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
};
export default nextConfig;The loader is global and handles each <Image src> by category:
| src | Handling |
|-------|----------|
| Product image (CDN URL from the GraphQL API) | Width set per srcset entry — drop transform: { maxWidth }, the loader owns the width |
| Local public/ image (e.g. /hero.webp) | Routed through the CDN, resized + format-negotiated |
| Image imported in code (import hero from './hero.webp') | Routed through the CDN — the framework bundles it under /_next/static/media/, the loader resizes it per srcset (no code change needed) |
| Other build asset (/_next/* JS/CSS, /_nuxt/, /_astro/, /_app/), absolute external URL, data: URI, SVG | Returned unchanged |
The loader reads NEXT_PUBLIC_SHOP_ID, NEXT_PUBLIC_DEPLOYMENT_COMMIT,
NEXT_PUBLIC_IMGPROXY_BASE, and NEXT_PUBLIC_ASSET_CLEAN_URL — all injected by the platform
at build/deploy time. When they are absent (e.g. local doswiftly dev, where public/ images
are served by the dev server) the loader leaves local public/ images untouched; product
images, being absolute CDN URLs, are unaffected. NEXT_PUBLIC_ASSET_CLEAN_URL is set to true
only when your storefront is served from a custom domain configured for clean URLs — then the
built URLs omit the internal storage prefix ({host}/_next/static/media/... instead of
{host}/s/{shopId}/_next/...); the stored object is unchanged. <Image quality> is a no-op —
quality is applied server-side (the format is still auto-negotiated AVIF/WebP). Pass explicit
overrides only for non-standard setups: createImageLoader({ shopId, version, cdnBase, cleanUrl }).
For non-<Image> usage (CSS backgrounds, raw <img>), build CDN URLs yourself
with the pure helper buildImageLoaderUrl from @doswiftly/storefront-sdk.
Middleware pipeline
Default order (wired automatically by StorefrontProvider):
auth → cart-secret → currency → language → bot-protection → [custom] → retry → timeout → errors (ALWAYS LAST)When session refresh is active, a reactive-401 middleware wraps the whole
pipeline: a 401 on a read query triggers one deduped refreshSession() and a
replay; a 401 on a mutation fires the session-expired signal instead. The
public catalog client and the customer-account client share a single renewal, so
a 401 arriving on both surfaces at once still makes only one same-origin
/refresh call (one cookie rotation, never two racing ones).
import {
authMiddleware, // Authorization: Bearer <token> (lazy getter)
cartSecretMiddleware, // x-cart-secret header (cart operations only)
currencyMiddleware, // X-Preferred-Currency header
languageMiddleware, // X-Language header (skipped when null — intentional)
botProtectionMiddleware, // challenge token for protected mutations only
retryMiddleware, // exponential backoff + jitter, honours Retry-After — queries only, never mutations
concurrencyMiddleware, // caps requests in flight (process-wide budget) — queues, never drops
timeoutMiddleware, // AbortController, edge-safe (default 5s)
errorMiddleware, // normalizes all errors → StorefrontError (ALWAYS LAST)
sessionRetryMiddleware, // reactive-401 refresh + replay (queries only)
} from '@doswiftly/storefront-sdk';
// Custom middleware
const logMiddleware: Middleware = async (request, next) => {
console.log('Request:', request.operationName);
const response = await next(request);
return response;
};Middleware that reads mutable state takes a lazy getter
(authMiddleware(() => store.getState().accessToken)) so rotated values are
picked up without rebuilding the client.
A server-only forwardedIpMiddleware is also available for server-rendered (BFF)
storefronts — it forwards the real buyer IP to the backend so per-IP rate limits
do not collapse onto the storefront server's address. See
Server-side.
Core API
createStorefrontClient
const client = createStorefrontClient({
apiUrl: string,
shopSlug: string,
middleware?: Middleware[],
defaultHeaders?: Record<string, string>,
fetch?: typeof globalThis.fetch, // custom fetch (polyfill, test mocks)
debug?: boolean | 'verbose' | DebugOptions,
});
client.query<T, V>(document, variables?, cache?): Promise<T>
client.mutate<T, V>(document, variables?): Promise<T>
client.use(middleware): void // imperative middleware addFeatures: lazy pipeline compilation, same-tick request deduplication (queries
only), TypedDocumentString support from graphql-codegen.
createCustomerClient(config) is the same factory with graphqlPath defaulted to
the customer-account surface (/storefront/customer/graphql) — see
Customer account surface.
Debug logging
createStorefrontClient({ apiUrl, shopSlug, debug: 'verbose' });debug: true— minimal: operation name + variables on request, status + userErrors on response.debug: 'verbose'— everything: full query, variables, headers, response body, timing, userErrors.debug: { request?, response?, headers?, timing?, userErrors?, log? }— granular per-dimension opt-in, plus a custom sink (log: (event: DebugEvent) => void) for routing into your logger.- Env fallback:
DOSWIFTLY_SDK_DEBUG=verbose|true|minimalwhen the option is omitted — disabled inNODE_ENV=production(PII safety). Authorization: Bearer …and auth-cookie values are unconditionally redacted to***<last4>whenever headers are logged.
createRemoteDebugTransport builds a shared remote sink — pass it as
debug: { remote: transport } on the client and as cookieDebug on the
providers so GraphQL operations and cookie writes land on one timeline with a
single session id.
StorefrontError
import { StorefrontError, ErrorCodes } from '@doswiftly/storefront-sdk';
try {
await client.query(ProductQuery, { handle: 'missing' });
} catch (err) {
if (err instanceof StorefrontError) {
err.code; // 'GRAPHQL_ERROR' | 'NETWORK_ERROR' | 'TIMEOUT' | 'USER_ERROR' | 'SESSION_EXPIRED' | ...
err.status; // HTTP status (0 for network errors)
err.graphqlErrors; // GraphQL-level errors
err.userErrors; // field-level validation errors (backend-translated messages)
err.hasUserErrors; // boolean
err.isNetworkError; // boolean
err.isTimeout; // boolean
}
}assertNoUserErrors(payload) — the helper the clients use internally — is also
exported for custom operations: it throws a StorefrontError carrying the first
backend-translated userErrors[].message.
Non-JSON responses (NON_JSON_RESPONSE)
A response whose body cannot be parsed as JSON — most often an HTML page substituted by a
proxy, a WAF challenge, or an edge block (e.g. Cloudflare) in front of the GraphQL endpoint —
throws a StorefrontError with code: 'NON_JSON_RESPONSE' and the real HTTP status from
the response (never 0 — a response was received, this is not a network failure):
import { StorefrontError, ErrorCodes, type NonJsonResponseDiagnostics } from '@doswiftly/storefront-sdk';
try {
await client.query(ProductQuery, { handle: 'x' });
} catch (err) {
if (err instanceof StorefrontError && err.code === ErrorCodes.NON_JSON_RESPONSE) {
err.status; // real HTTP status, e.g. 403
const diagnostics = err.cause as NonJsonResponseDiagnostics;
diagnostics.contentType; // response Content-Type header, or null
diagnostics.cfRay; // Cloudflare cf-ray header, or null — handy for support correlation
diagnostics.bodySnippet; // first ~200 characters of the body (trimmed) — enough to spot a challenge/error page
}
}err.isNonJsonResponse is a convenience getter for the same check, alongside the existing
isNetworkError / isTimeout / isAborted.
Retry behaviour: retryMiddleware treats a status < 500 non-JSON response as a
deterministic block (a WAF rule, an interactive challenge, a block page) — retrying would hit
the same page again, so the request is not retried. A status >= 500 non-JSON body (e.g. an
nginx or load-balancer HTML error page) is treated as transient and retried like any other 5xx.
The one exception is HTTP 429: a rate limit is transient whatever the body looks like, so it
is retried (see below).
Rate limits (HTTP 429)
429 is the only 4xx retryMiddleware retries — the identical request succeeds once the
limit window rolls. Queries only; a mutation is never retried, because it may already have
applied.
When the server sends Retry-After, that window is honoured exactly (jitter can push the
wait up, never below it). Without one, the middleware falls back to its own exponential
backoff. A window longer than maxRetryAfterMs (default 30_000) is refused outright rather
than waited out — a page render or a build needs an answer more than it needs to sit idle:
client.use(retryMiddleware({ maxRetries: 2, maxRetryAfterMs: 30_000 }));The parsed window is on the error, so your own code can react to it:
catch (err) {
if (err instanceof StorefrontError && err.isRateLimited) {
err.retryAfterMs; // ms the server asked you to wait, or undefined if it did not say
}
}If you fetch outside the SDK, parseRetryAfterMs(header) is exported for the same purpose —
it accepts both header forms (a delay in seconds and an HTTP-date) and returns undefined
when the value is absent, unparsable, or already elapsed.
Where a build reads its data from
If your shop is served from its own api.{apex}, that host sits behind your Cloudflare zone's
bot protection. A build is precisely what that protection challenges — one address issuing
hundreds of requests in seconds — and it cannot be waived for it, so builds could be challenged
or rate-limited on the very host your storefront uses at runtime.
getStorefrontClient therefore reads from the platform host while a build is running, and
from your configured apiUrl everywhere else. The browser bundle is untouched, so shoppers keep
using your own API host:
const client = getStorefrontClient({
apiUrl: process.env.NEXT_PUBLIC_API_URL!, // shoppers, at runtime
shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG!,
// buildApiUrl: 'https://api.doswiftly.pl', // optional — pin it yourself
// buildApiUrl: null, // optional — never swap hosts
});The deploy pipeline supplies the build host as DOSWIFTLY_BUILD_API_URL.
If you fetch data with your own code rather than the SDK client, you do not have to do anything: the deploy pipeline redirects the build's requests to the platform host from inside the build process, so your own helpers are covered too.
Bounding requests in flight
Unbounded fan-out from one process is what rate limits punish. A production build renders every
page in one process and fires its data requests as fast as it schedules them — hundreds within
seconds, all from one address; to a per-address rate limit that is indistinguishable from a
scraper, and the tail of it comes back 429, leaving pages rendered with empty data. A sitemap
route walking the whole catalog or a bulk migration script has the same shape.
getStorefrontClient takes two bounds. Requests are queued, never dropped — they change when
a request runs, never whether:
const client = getStorefrontClient({
apiUrl,
shopSlug,
maxConcurrency: 6, // at most 6 requests in flight
minRequestIntervalMs: 200, // starts no closer than 200 ms apart (≈ 300 req/min)
});Both matter, and for different reasons. A ceiling on requests in flight stops a burst from opening hundreds of sockets at once, but it does not bound requests per minute — which is what a rate limit measures. Six slots against 100 ms responses still sustains ~60 requests per second. Spacing is what bounds that.
The budget belongs to the process, not to the client object, so building a fresh client per request (which request-scoped headers force you to do) still counts against one budget.
When you pass nothing, a deploy build supplies both bounds itself
(DOSWIFTLY_BUILD_CONCURRENCY, default 6; DOSWIFTLY_BUILD_MIN_INTERVAL_MS, default 200) and
live server rendering stays unpaced, so no visitor request ever queues behind another. An
explicit value always wins over those defaults; 0 disables that bound.
For a client built with createStorefrontClient, wire concurrencyMiddleware({ max, minIntervalMs })
yourself. Two calls with the same settings return the same gate — that is what makes the budget
process-wide. Distinct settings get distinct budgets, so pick one configuration per process.
Schema enums — runtime constants
Every schema enum is exported as a runtime const + type alias pair, so both
import type { DeliveryType } and Object.values(DeliveryType) work:
import { DeliveryType, PaymentMethodType, CountryCode } from '@doswiftly/storefront-sdk';
const schema = z.enum(Object.values(PaymentMethodType)); // runtime validation
type T = DeliveryType; // 'HOME' | 'PICKUP_POINT' | 'LOCKER'Available: DeliveryType, PaymentMethodType, PaymentInitiationFlow,
CurrencyCode, CountryCode, LanguageCode, ProductTypeEnum, WeightUnit,
CartWarningCode, AttributeType, AttributeFillingMode,
AttributeBillingMode, AttributeOptionSurchargeType, StorefrontOrderStatus,
OrderPaymentStatus, OrderFulfillmentStatus, DiscountErrorCode,
DiscountApplicationType, PaymentProvider, PaymentInstrumentType,
PaymentInstrumentDisplayHint, PaymentMethodUnavailableReason.
Format utilities
import {
formatPrice, formatPriceRange, formatAmount,
formatDate, formatDateTime, formatNumber, formatPercentage,
getCurrencySymbol,
} from '@doswiftly/storefront-sdk';
formatPrice({ amount: '99.99', currencyCode: 'USD' }); // "$99.99"
formatAmount('115.20', 'EUR'); // "115,20 €"
formatPercentage(0.15); // "15%"Locale-bound versions that follow the active storefront language are available as React hooks — see Format hooks.
HTML sanitizer + connection normalizer
import { sanitizeHtml, normalizeConnection } from '@doswiftly/storefront-sdk';
const safe = sanitizeHtml(userHtml); // strips <script>, event handlers, javascript: URLs
const { items, pageInfo, totalCount } = normalizeConnection(data.products); // Relay → flat arrayCookie contracts (platform constants)
All first-party cookie names/defaults the platform relies on are exported — never hardcode the strings:
| Constant | Cookie | Notes |
|---|---|---|
| AUTH_COOKIE_NAME / AUTH_COOKIE_DEFAULTS | customerAccessToken | httpOnly access token |
| REFRESH_COOKIE_NAME / REFRESH_COOKIE_DEFAULTS | customerRefreshToken | httpOnly, path-scoped to the auth route |
| SESSION_EXPIRY_COOKIE_NAME / SESSION_EXPIRY_COOKIE_DEFAULTS | session-expiry | readable expiry hint for the scheduler |
| CART_COOKIE_NAME / CART_COOKIE_MAX_AGE | cart-id | composite "<cartId>.<secret>", 30 days |
| CURRENCY_COOKIE_NAME / CURRENCY_COOKIE_MAX_AGE / CURRENCY_HEADER_NAME | preferred-currency | |
| LANGUAGE_COOKIE_NAME / LANGUAGE_COOKIE_MAX_AGE / LANGUAGE_HEADER_NAME | preferred-language | |
import { parseCartCookieValue, formatCartCookieValue } from '@doswiftly/storefront-sdk';
parseCartCookieValue('abc.s3cret'); // { cartId: 'abc', cartSecret: 's3cret' }
parseCartCookieValue('abc'); // { cartId: 'abc'