Skip to content
Coritan Docs

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.

View as Markdown

@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.

  • 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 is starters/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.

Run npm pack in the SDK's folder. It makes coritan-commerce-0.1.0.tgz, which you then install in your storefront's folder:

Shell
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.

A client talks to one store:

JavaScript
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.

Every 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.

JavaScript
const { cart } = await commerce.carts.updateLineItem(cartId, lineId, { quantity: 2 });

options takes:

  • signal, an AbortSignal that cancels the request;
  • headers, sent with this request only;
  • customerToken, a shopper's token for this request, or null to send none;
  • token, on orders.retrieve, orders.documents, returns.list and returns.create: the order's access token from its link, for a shopper who is not signed in;
  • fetchOptions, passed to fetch as 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.

customers.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:

JavaScript
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, and setCustomerToken(null) forgets both tokens.
  • When your organization checks sign-ups with Cloudflare Turnstile, store.get() answers turnstile with enabled: true and the widget's site_key. Render the widget with that key, and send its answer as turnstile_token with customers.register. A sign-up without one, or with an answer from a widget with another key, rejects with 403 and turnstile_failed.

When 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:

JavaScript
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.

A 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 }).

TypeScript
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.

This test checkout pays with the manual provider, which only test carts offer and which charges nothing:

JavaScript
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.

  • carts.complete always sends an Idempotency-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 as cart_not_ready.
  • After no answer, a 500, 502, 503 or 504, or a 409 with idempotency_in_progress or cart_completing, the client sends the same request again. It tries up to retries more times (3 by default), first after retryDelay milliseconds (500 by default) and then twice as long each time. It waits out a Retry-After of up to 10 seconds instead.
  • When the retries run out, or your signal cancels 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 idempotencyKey to use a key of your own, such as one you keep with the checkout, and retries: 0 to handle every answer yourself. accept_terms: true in the same options sends the shopper's acceptance of your terms.

Every refusal rejects with a CommerceError:

JavaScript
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.

Every 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.

JavaScript
import { formatMoney } from "@coritan/commerce";

formatMoney(4900, "EUR", "de-DE"); // "49,00 €"

returns.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:

JavaScript
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 starter

The 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:

Shell
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.

Build the image from the folder that holds both sdk and starters:

Shell
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_url to 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/return on the starter's own address, which must be that origin or one in allowed_origins.
  • Emails that confirm an address or reset a password link to /verify-email and /reset-password on 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.

With 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"}.

TypeError: 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 as createClient({ fetch }).
401 once 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 with autoRefresh: false, renews only when you call customers.refresh({ refresh_token }).
403 with turnstile_failed
The sign-up carried no Turnstile answer, or one from a widget with another site key. Render the widget with the site_key from store.get(). In the starter, set NEXT_PUBLIC_TURNSTILE_SITE_KEY to it.
409 with cart_completing or idempotency_in_progress once the retries run out
Another request is still placing the order. Call carts.complete again in a moment. It sends the same key, and answers the order once it is placed.
429 with rate_limited from 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. retryAfter says how long to wait.
The starter's npm install cannot find @coritan/commerce
The starter installs the SDK from ../../sdk/commerce-js. Put the SDK's folder there, beside starters.
Every page of the starter lists settings to set
One of the three required variables is missing. Set it, then restart the server.