Build a storefront with the JavaScript SDK
Call the Store API from JavaScript with our commerce client, or start from the Next.js storefront built on it.
@coritan/commerce is our JavaScript client for the Store API. It has a method for each Store API route and for customer sign-in, keeps the shopper signed in, and retries a checkout without placing the order twice. It is an ES module with no dependencies that uses the runtime's fetch, so it runs in a browser, in Node.js 18 or later, and in Next.js server code.
The Next.js starter is a whole storefront built on the SDK. Start from it to have a working store to change, or add the SDK to a site you already have.
Before you begin
Section titled Before you begin- Set up the store and create a publishable key, as Build a checkout with the Store API describes. Build with a test key, whose token starts with
pk_test_. - Ask support for a copy of the SDK, and of the starter if you want to start from it. They are not on npm. The SDK is the folder
sdk/commerce-js, and the starter isstarters/nextjs-storefront, which expects the SDK at that path beside it. - Use Node.js 18 or later with the SDK, and Node.js 20 or later with the starter.
Install the SDK
Section titled Install the SDKRun npm pack in the SDK's folder. It makes coritan-commerce-0.1.0.tgz, which you then install in your storefront's folder:
cd sdk/commerce-js && npm pack
cd ../../my-storefront && npm install ../sdk/commerce-js/coritan-commerce-0.1.0.tgz
Import it as @coritan/commerce. The package carries its own TypeScript types, which cover every call and every answer.
Create a client
Section titled Create a clientA client talks to one store:
import { createClient } from "@coritan/commerce";
const commerce = createClient({
baseUrl: "https://api.coritan.com",
store: "acme",
publishableKey: "pk_test_...",
});
const { products } = await commerce.products.list({ collection_handle: "clothing", limit: 12 });
| Option | What it does |
|---|---|
baseUrl |
The API's address, https://api.coritan.com. Requests go to {baseUrl}/api/v1/orgs/{store}/.... |
store |
Your organization's slug. |
publishableKey |
The store's publishable key. The client sends it as x-publishable-api-key on every request. |
storage |
Where to keep the shopper's tokens between page loads, such as window.localStorage. Any object with getItem, setItem and removeItem works, and its methods may return promises. |
storageKey |
The start of the names the tokens are stored under. The default is coritan.commerce.{store}. |
customerToken |
A shopper's access token to start with. |
autoRefresh |
Whether the client renews an expired access token and sends the request again. It is on unless you set false. |
headers |
Headers to send with every request. |
fetch |
A fetch to use instead of the runtime's own. |
createClient throws a TypeError when baseUrl is not an absolute http or https address, or when store or publishableKey is empty.
Call a route
Section titled Call a routeEvery method takes the IDs in the route's path first, then the request body with the API's own field names, then options. It resolves to the API's JSON answer as it is, so Build a checkout with the Store API describes each answer.
const { cart } = await commerce.carts.updateLineItem(cartId, lineId, { quantity: 2 });
options takes:
signal, anAbortSignalthat cancels the request;headers, sent with this request only;customerToken, a shopper's token for this request, ornullto send none;token, onorders.retrieve,orders.documents,returns.listandreturns.create: the order's access token from its link, for a shopper who is not signed in;fetchOptions, passed tofetchas they are, such as Next.js's{ next: { revalidate: 60 } }.
| Methods | Routes |
|---|---|
store.get |
GET /store |
regions.list, regions.retrieve |
/store/regions and /store/regions/{region_id} |
products.list, products.retrieve |
/store/products and /store/products/{id_or_handle} |
collections.list, collections.retrieve |
/store/collections and /store/collections/{handle} |
categories.list, categories.retrieve |
/store/categories and /store/categories/{handle} |
carts.create, carts.retrieve, carts.update |
/store/carts and /store/carts/{cart_id} |
carts.addLineItem, carts.updateLineItem, carts.removeLineItem |
/store/carts/{cart_id}/line-items |
carts.applyPromotion, carts.removePromotion, carts.applyGiftCard, carts.removeGiftCard |
/store/carts/{cart_id}/promotions and /gift-cards |
carts.listShippingOptions, carts.addShippingMethod |
/store/carts/{cart_id}/shipping-options and /shipping-methods |
carts.listPaymentProviders, carts.createPaymentSession, carts.complete |
/store/carts/{cart_id}/payment-providers, /payment-sessions and /complete |
customers.register, customers.login, customers.refresh, customers.logout |
POST /auth/register, /auth/login, /auth/refresh and /auth/logout |
customers.account |
GET /auth/me |
customers.me, customers.update |
GET and PATCH /store/customers/me |
customers.addresses.list, .create, .update, .delete |
/store/customers/me/addresses |
orders.list |
GET /store/customers/me/orders |
orders.retrieve, orders.documents, orders.lookup |
GET /store/orders/{order_id} and its /documents, and POST /store/orders/lookup |
returns.reasons, returns.list, returns.create |
GET /store/return-reasons, and /store/orders/{order_id}/returns |
request({ method, path, query, body }) calls any other route under /api/v1/orgs/{store}, with the key and the shopper's token. It also takes idempotencyKey, shopper: false to send no shopper's token, and signal, headers, customerToken and fetchOptions.
Sign shoppers in
Section titled Sign shoppers incustomers.login and customers.register take the fields that Sign customers in to your storefront lists, and keep the tokens the answer holds. Later requests send the access token as Authorization: Bearer. In a browser, give the client storage so the shopper stays signed in across page loads:
const commerce = createClient({ baseUrl, store, publishableKey, storage: window.localStorage });
await commerce.customers.login({ email: "alex@example.com", password });
const { orders } = await commerce.orders.list();
- When the access token has expired, the client renews it once with the refresh token and sends the request again. Requests that fail together share one renewal. When the API refuses the refresh token too, the client forgets the session, and the request rejects with its
401. customers.logout()signs the shopper out on our side and forgets the tokens.setCustomerToken(token)sets an access token you keep elsewhere, andsetCustomerToken(null)forgets both tokens.- When your organization checks sign-ups with Cloudflare Turnstile,
store.get()answersturnstilewithenabled: trueand the widget'ssite_key. Render the widget with that key, and send its answer asturnstile_tokenwithcustomers.register. A sign-up without one, or with an answer from a widget with another key, rejects with403andturnstile_failed.
Finish a two-factor sign-in
Section titled Finish a two-factor sign-inWhen the shopper's account uses two-factor authentication, customers.login answers mfa_required: true and an mfa_token instead of the session, and the client stays signed out. When your organization requires it and the shopper has not set it up, customers.login and customers.register answer mfa_setup_required: true instead. The SDK has no methods for these steps, so send them with request(), as Two-factor authentication describes:
const answer = await commerce.customers.login({ email: "alex@example.com", password });
if (answer.mfa_required) {
const tokens = await commerce.request({
method: "POST",
path: "/auth/mfa/verify",
body: { code },
customerToken: answer.mfa_token,
});
await commerce.setCustomerToken(tokens.access_token);
}
setCustomerToken keeps only the access token, so this client cannot renew the session by itself. Keep tokens.refresh_token, and renew with customers.refresh({ refresh_token }), which keeps the new pair in the client.
Use the SDK on a server
Section titled Use the SDK on a serverA client holds one shopper's session, and a server serves every shopper. Make a client for each request with that shopper's token, and never keep a signed-in client where other requests can reach it. Renew the session where you can store the new pair, such as in cookies, with customers.refresh({ refresh_token }).
import { cookies } from "next/headers";
import { createClient } from "@coritan/commerce";
export async function shopperClient() {
const jar = await cookies();
return createClient({
baseUrl: "https://api.coritan.com",
store: "acme",
publishableKey: process.env.NEXT_PUBLIC_CORITAN_PUBLISHABLE_KEY!,
customerToken: jar.get("shopper_access")?.value ?? null,
autoRefresh: false,
});
}
Cache the answers that are the same for every shopper, such as the catalogue for a visitor who is not signed in, with fetchOptions: { next: { revalidate: 60 } }. Send a signed-in shopper's requests uncached, because their customer groups can give them other prices.
Important
The Store API counts some limits per IP address, such as 60 payment sessions in 10 minutes. Requests from your server count against your server's address, whichever shopper they are for, and the API ignores an x-real-ip or X-Forwarded-For header your server sends. When many shoppers can check out at once, call sign-in and checkout from the browser, from an origin the store allows, so that each shopper's own address counts.
Check out
Section titled Check outThis test checkout pays with the manual provider, which only test carts offer and which charges nothing:
const { store } = await commerce.store.get();
let { cart } = await commerce.carts.create({
region_id: store.default_region_id,
email: "alex@example.com",
items: [{ variant_id: 1841, quantity: 1 }],
});
({ cart } = await commerce.carts.update(cart.id, {
shipping_address: {
first_name: "Alex",
last_name: "Doe",
address_1: "1 Example Street",
city: "Berlin",
postal_code: "10117",
country_code: "DE",
},
}));
const { shipping_options } = await commerce.carts.listShippingOptions(cart.id);
({ cart } = await commerce.carts.addShippingMethod(cart.id, { option_id: shipping_options[0].id }));
await commerce.carts.createPaymentSession(cart.id, { provider: "manual", accept_terms: true });
const { order, access_token } = await commerce.carts.complete(cart.id);
Before payment, cart.blockers lists what stops checkout, and an empty list means the cart can be placed. With Stripe or PayPal, the payment session carries what the browser needs, as Take payment describes. The access_token opens the order without a sign-in: pass it as token to orders.retrieve.
Complete the order safely
Section titled Complete the order safelycarts.completealways sends anIdempotency-Key. The client makes one for each cart and sends it again on every retry, until the API gives a final answer: the order, or a refusal such ascart_not_ready.- After no answer, a
500,502,503or504, or a409withidempotency_in_progressorcart_completing, the client sends the same request again. It tries up toretriesmore times (3 by default), first afterretryDelaymilliseconds (500 by default) and then twice as long each time. It waits out aRetry-Afterof up to 10 seconds instead. - When the retries run out, or your
signalcancels the request, the client keeps the key. The shopper's next press of the pay button sends the same request, so it cannot place a second order. - A client made for each request starts with no key. A completed cart still answers its order again, and a completion that is still running answers
cart_completing, which the client waits out. - Pass
idempotencyKeyto use a key of your own, such as one you keep with the checkout, andretries: 0to handle every answer yourself.accept_terms: truein the same options sends the shopper's acceptance of your terms.
Handle errors
Section titled Handle errorsEvery refusal rejects with a CommerceError:
import { CommerceError } from "@coritan/commerce";
try {
await commerce.carts.applyPromotion(cart.id, { code: "WELCOME10" });
} catch (error) {
if (!(error instanceof CommerceError)) throw error;
showMessage(error.message);
}
| Field | Holds |
|---|---|
status |
The HTTP status, or 0 when no answer came. |
code |
The API's error code, such as cart_not_ready. A body that fails validation gives validation_error. An answer with no code gets one from its status, such as not_found, rate_limited or server_error. With no answer, it is network_error, or aborted when your signal cancelled the request. |
message |
The API's sentence, or ours when no answer came. |
details |
The rest of the answer, such as blockers or available. For validation_error, details.errors lists each problem's field and message. |
retryAfter |
How many seconds to wait, when the API said. |
An error does not hold the request's headers, where the shopper's token travels, and its path leaves out the query string, where an order link's token travels. The client never writes to the console.
Show amounts
Section titled Show amountsEvery amount the API sends is a whole number in the currency's minor unit: 4900 is €49.00 in euros, and ¥4,900 in yen. formatMoney(amount, currencyCode, locale) formats one for the shopper. toMajorUnits and currencyDecimals do the arithmetic, and count the same currencies in whole units as the API does.
import { formatMoney } from "@coritan/commerce";
formatMoney(4900, "EUR", "de-DE"); // "49,00 €"
Take returns
Section titled Take returnsreturns.reasons(), returns.list(orderId, options) and returns.create(orderId, body, options) call the routes in Let the shopper ask for a return. Pass the order's access token as token when the shopper is not signed in. returns.create sends an Idempotency-Key only when you put one in headers, with a value you make once for each request and send again on a retry:
const { return_reasons } = await commerce.returns.reasons();
const { return: created } = await commerce.returns.create(
order.id,
{ items: [{ order_item_id: order.items[0].id, quantity: 1, reason_code: return_reasons[0]?.code }] },
{ token, headers: { "Idempotency-Key": returnKey } },
);
Start from the Next.js starter
Section titled Start from the Next.js starterThe starter is a storefront on Next.js 15 and React 19. It has the catalogue, product pages priced for the shopper's region, the cart, and checkout with Stripe, PayPal or the test provider. Shoppers see their orders with invoices and returns, look up a guest order by email, and manage an account.
Every Store API call runs on the starter's server. The shopper's tokens, cart and region are in HttpOnly cookies, and middleware.ts renews an expired access token before a page renders. It reads these variables when a request arrives, so one build serves any store, and a change needs a restart but no new build:
| Variable | Needed | What it holds |
|---|---|---|
NEXT_PUBLIC_CORITAN_API_URL |
Yes | https://api.coritan.com |
NEXT_PUBLIC_CORITAN_STORE |
Yes | Your organization's slug |
NEXT_PUBLIC_CORITAN_PUBLISHABLE_KEY |
Yes | The store's publishable key |
NEXT_PUBLIC_TURNSTILE_SITE_KEY |
When sign-ups use Turnstile | The widget's site key |
The sign-up form shows the Turnstile widget only when NEXT_PUBLIC_TURNSTILE_SITE_KEY is set. When GET /store answers turnstile with enabled: true, set the variable to its site_key, or every sign-up answers 403 with turnstile_failed.
Run it on your computer:
cd starters/nextjs-storefront
npm install
cp .env.example .env.local
npm run dev
Fill in .env.local before npm run dev, which serves the store at http://localhost:3000. Until the three required variables are set, every page says which ones are missing. npm run build and npm start serve a production build on $PORT, or port 3000. With a pk_test_ key, checkout offers the test provider, and the footer says that nothing is charged.
The starter installs the SDK from ../../sdk/commerce-js, and its next.config.ts and Dockerfile expect the same layout. Keep the two folders at sdk/commerce-js and starters/nextjs-storefront inside one folder.
Deploy the starter
Section titled Deploy the starterBuild the image from the folder that holds both sdk and starters:
docker build -f starters/nextjs-storefront/Dockerfile -t storefront .
docker run -p 3000:3000 --env-file starters/nextjs-storefront/.env.local storefront
The image runs as the unprivileged node user and listens on $PORT, 3000 by default. /api/health answers 200 once the three variables are set and 503 with the missing ones until then. It never calls the API, so an outage of the API does not fail it.
To run it on Coritan Apps, push both folders to a git repository at the same paths and create an app from it. Leave the app's directory empty, so the build starts at the repository's root, and set the Dockerfile path to starters/nextjs-storefront/Dockerfile. Keep port 3000, set the health check path to /api/health, and give the running app the variables above.
Then point the store and your organization at it:
- Set the store's
storefront_urlto the starter's address, as Change the store's settings shows. Order emails link to/orders/{order_id}there, and PayPal sends the shopper back to/checkout/returnon the starter's own address, which must be that origin or one inallowed_origins. - Emails that confirm an address or reset a password link to
/verify-emailand/reset-passwordon your organization's custom domain, and the starter serves both. Point that domain at the starter, as Change the branding and legal details describes.
To make the store yours, change the colours and type at the top of app/globals.css, the home page's copy in app/page.tsx, and the messages shoppers see for each error code in lib/errors.ts.
Result
Section titled ResultWith a test key, your storefront lists the catalogue, fills a cart and places a test order that charges nothing. The order appears in the Commerce API's GET /commerce/orders?livemode=false. A deployed starter's /api/health answers 200 with {"status": "ok"}.
Troubleshooting
Section titled TroubleshootingTypeError: No fetch on this runtime- The runtime has no global
fetch, as in Node.js before version 18. Use a later Node.js, or pass one ascreateClient({ fetch }). 401once the access token has expired- The client holds no refresh token, or the API refused it, so the client forgot the session. Sign the shopper in again. A client given only a
customerToken, or withautoRefresh: false, renews only when you callcustomers.refresh({ refresh_token }). 403withturnstile_failed- The sign-up carried no Turnstile answer, or one from a widget with another site key. Render the widget with the
site_keyfromstore.get(). In the starter, setNEXT_PUBLIC_TURNSTILE_SITE_KEYto it. 409withcart_completingoridempotency_in_progressonce the retries run out- Another request is still placing the order. Call
carts.completeagain in a moment. It sends the same key, and answers the order once it is placed. 429withrate_limitedfrom a server- Every shopper your server calls for shares its address's limits. Call sign-in and checkout from the browser, as Use the SDK on a server explains.
retryAftersays how long to wait. - The starter's
npm installcannot find@coritan/commerce - The starter installs the SDK from
../../sdk/commerce-js. Put the SDK's folder there, besidestarters. - Every page of the starter lists settings to set
- One of the three required variables is missing. Set it, then restart the server.