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.2.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.2.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.
serverKey On a server only: an organization API key with the commerce.storefront:forward scope. The client sends it as X-API-Key, with the visitor's address and browser, on the routes that believe them, and only for a call that has an address.
visitor { address, userAgent } of the visitor this client serves. It takes effect only with a serverKey. A call's own visitor option replaces it for that call, and null sends none.
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, when store or publishableKey is empty, or when it is given a serverKey in a browser, where any script on the page can read the key.

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.listFees, carts.addFee, carts.removeFee /store/carts/{cart_id}/fees and /fees/{code}
carts.listPaymentProviders, carts.createPaymentSession, carts.complete /store/carts/{cart_id}/payment-providers, /payment-sessions and /complete
paymentReturns.maef POST /store/payment-returns/maef
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, or one order with { display_id }
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
carts.recover GET /store/carts/recover?t=
leads.create, leads.impression POST /store/leads and /store/leads/impressions
unsubscribe POST /store/unsubscribe

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,
  });
}

For 10 seconds after a write, a client sends coritan-read-after with its requests, so they see that write even when the API would otherwise answer from a copy of the database that is a moment behind. A client made for one request keeps nothing for the next. When a server action writes and then redirects, keep Date.now() in a cookie that lasts 10 seconds and send it with the next request as headers: { "coritan-read-after": value }.

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. An IPv6 address counts as its /64 network. 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. To count each shopper, give the client a serverKey and the request's visitor, as Server-side storefronts: pass the visitor's address explains. Without them, a server on App Deployment counts as every shopper of the store and gets 100 times the per-shopper limits, and a server anywhere else shares one set of the ordinary limits.

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.listPaymentProviders also answers route. When your store routes payments, it is the cart's route, default or rollout, and the list holds only that route's providers. Without routing it is null. Offer exactly what the answer lists: a provider the route does not hold rejects with 409 and payment_provider_not_offered.

Pay through your MAEF parent site

Section titled Pay through your MAEF parent site

The provider external is your store's payment page, which hands the cart to your MAEF parent site. Show it with its config.display_name, config.description and config.brands, then send the shopper to the session's redirect_url, the parent's own payment page:

JavaScript
const { payment_session } = await commerce.carts.createPaymentSession(cart.id, {
  provider: "external",
  accept_terms: true,
});
window.location.assign(payment_session.redirect_url);

The parent sends the shopper back to /thank-you on the address it holds for your site (we hand it your storefront_url), and maef_complete, sig and maef_parent in the query. Pass that query on, untouched, with paymentReturns.maef. We check the parent's signature, so the storefront needs no secret:

JavaScript
const { outcome, cart_id } = await commerce.paymentReturns.maef(window.location.search);
if (outcome === "placed") {
  const { order, access_token } = await commerce.carts.complete(cart_id);
}

outcome is placed, processing, failed or review, as Take the parent's return describes. Passing the same return again answers what the first one came to, so a page that loads twice is safe. Until the return arrives, carts.complete rejects with 409 and payment_processing, and the client does not retry it.

  • 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, your MAEF parent site 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.

To run it in Docker on your own machine or in your CI, 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 App Deployment, push both folders at the same paths to a public repository on GitHub, GitLab or Bitbucket. Then open your organization's Commerce tab. While App Deployment is on, its Overview shows owners and admins a Storefront card:

  1. Select Deploy storefront.
  2. In Repository, type your repository's https:// address. Left empty, the storefront is built from our own copy of the starter, when we publish one.
  3. In Name, type the deployment's name, or leave it for <your slug>-storefront.
  4. Under Billing, choose Pay as you go, billed by the hour from the owner's credit, or Monthly, one invoice a month that is paid before the storefront starts.
  5. Select Deploy storefront.

This orders a deployment that builds the starter from starters/nextjs-storefront with the Next.js preset (Frameworks we build), on port 3000 with the health check path /api/health. It makes the storefront a publishable key, live when the store takes live orders and a test key otherwise, and sets the three required variables. The deployment is your organization's and is billed to its owner's account (How App Deployment is billed). When its first invoice has to be paid, the dialog says Waiting for payment, and the owner pays it with Pay the invoice. Once it is paid, or at once when nothing is owed, the deployment is built and appears under App Deployment and on the organization's App Deployment tab within a few minutes.

POST /api/v1/orgs/{org_slug}/commerce/storefront/deploy places the same order. Its body takes name, location (one location's code, such as fra), billing (payg or commit), livemode, and repository with repo_url and branch. It answers 201 with the order, the new key and the variables, and deployment.uuid stays null until the deployment is made. Because it orders a paid service, it takes an owner or an admin who signs in: an organization API key answers 403 with people_only. After five orders in an hour it answers 429.

To set it up by hand instead, order a deployment built from the repository (Order your code). Set its root directory to starters/nextjs-storefront and its Framework to Next.js, keep port 3000, set the health check path to /api/health, and give it the variables above. The build checks out the whole repository, so the starter finds ../../sdk/commerce-js. The starter's Dockerfile is not built on App Deployment; to run the image instead, build it in your CI and deploy it as a container image (Dockerfile? Deploy a container image).

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, PayPal sends the shopper back to /checkout/return on the starter's own address, which must be that origin or one in allowed_origins, and your MAEF parent sends them back to /thank-you on the storefront_url.
  • 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.

The starter's /auth/callback page takes the support sessions that your team and Coritan support open as a shopper, as Receive a support session describes. It removes the token from the address bar, checks it with GET /auth/me, and refuses a token that is not a support session's with This link has expired or is not a support session. It sets the shopper's own session and cart aside in a cookie, keeps the support token with no refresh token, and opens next or /account.

While the session lasts, a banner above every page names the account, says when the session ends and has End session. End session, and Sign out on the account pages, sign the token out with POST /auth/logout, give the shopper's session and cart back and open the home page. When the 30 minutes are up, or the API stops taking the token, the next page gives them back too. The starter does not call the /portal/servers/ routes, so a servers session is read-only there, and it does not read from or customer.

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.
409 with payment_processing when the shopper is back from your MAEF parent site
We have not had the parent's return yet. Check that your /thank-you page passes its query to paymentReturns.maef, then call carts.complete again every few seconds. After 15 minutes without a return, it answers payment_requires_action and the shopper can pay again. Take payments through a MAEF parent site explains the return.
429 with rate_limited from a server
Every shopper your server calls for shares its address's limits. Give the client a serverKey and the request's visitor, as Server-side storefronts: pass the visitor's address explains, so that each shopper counts on their own. 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.
Name your copy of the storefront starter as repository: a public repository that holds starters/nextjs-storefront and sdk/commerce-js.
Deploy storefront has no copy of ours to build from. Push both folders to a public repository of your own and type its address in Repository. The API answers the same with 409 and starter_unavailable.

API operations on this page

MethodPathWhat it does
POST/api/v1/orgs/{org_slug}/commerce/storefront/deployDeploy storefront