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

Source: https://www.coritan.com/docs/organizations/storefront/commerce-sdk/

`@coritan/commerce` is our JavaScript client for the [Store API](/docs/organizations/storefront/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

- Set up the store and create a publishable key, as [Build a checkout with the Store API](/docs/organizations/storefront/store-api/#before-you-begin) describes. Build with a test key, whose token starts with `pk_test_`.
- Ask [support](https://www.coritan.com/dashboard/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.

## Install the SDK

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:

```bash
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

A client talks to one store:

```js
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

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](/docs/organizations/storefront/store-api/) describes each answer.

```js
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`.

## Sign shoppers in

`customers.login` and `customers.register` take the fields that [Sign customers in to your storefront](/docs/organizations/storefront/customer-sign-in/) 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:

```js
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`.

### Finish a two-factor sign-in

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](/docs/organizations/storefront/customer-sign-in/#two-factor-authentication) describes:

```js
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

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.

## Check out

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

```js
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](/docs/organizations/storefront/store-api/#take-payment) describes. The `access_token` opens the order without a sign-in: pass it as `token` to `orders.retrieve`.

### Complete the order safely

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

## Handle errors

Every refusal rejects with a `CommerceError`:

```js
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

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.

```js
import { formatMoney } from "@coritan/commerce";

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

## Take returns

`returns.reasons()`, `returns.list(orderId, options)` and `returns.create(orderId, body, options)` call the routes in [Let the shopper ask for a return](/docs/organizations/storefront/store-api/#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:

```js
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

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:

```bash
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

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

```bash
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](/docs/organizations/storefront/commerce-api/#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](/docs/organizations/settings/#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

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

## Troubleshooting

`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](#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.

## Related

- [Build a checkout with the Store API](/docs/organizations/storefront/store-api/)
- [Sign customers in to your storefront](/docs/organizations/storefront/customer-sign-in/)
- [Sell with the Commerce API](/docs/organizations/storefront/commerce-api/)
- [Handle returns, exchanges and claims](/docs/organizations/storefront/commerce-returns/)
