Overview

What the shared API client is, why every request should go through it, and how to make a request in a loader, a component or directly.

Read this first when your theme needs store data: products, the cart, orders, settings. Everything comes from the store API, and the engine puts one HTTP client in front of it.

Framework: TanStack Start

The loader examples use TanStack Start, the default and currently supported framework. See what depends on the framework.

Three layers

queries      cart.queries.detail(id)     TanStack Query options for components (cache, Suspense)
modules      cart.get(id), product.find  plain async functions with typed results
client       api                         base URL, locale, store, token, scope, timeout, retries
  1. The client (api) adds the locale, store, login token and branch to every request.
  2. Modules wrap one area each (cart, product, order and so on) as plain async functions. See all modules.
  3. queries on a module are TanStack Query queryOptions, ready for useQuery in a component.

Pick the highest layer that does the job. Each one is built on the one below.

Make a request

Start with a module. Import it from its own subpath:

import { cart } from '@salla.sa/twilight-theme-engine/api/cart';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { order } from '@salla.sa/twilight-theme-engine/api/order';

const item = await product.find('123');                    // one product, with details
const latest = await product.list({ source: 'latest' });   // { items, next, filters }
const myCart = await cart.get(cartId);                     // cartId is a number
const orders = await order.list({ status: 'completed' });  // { data, cursor }

Parameters and results are typed, so your editor shows what each function accepts.

In a route loader

A loader runs before the page renders, on the server for the first visit. Fetch through the query client so the result is cached and sent to the browser with the page (see Loading data):

app/routes/sale.tsx
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import { product } from '@salla.sa/twilight-theme-engine/api/product';

loader: () => {
  const { queryClient } = getTwilightContext();
  return queryClient.fetchQuery(product.queries.list({ source: 'offers' }));
},

Calling product.list(...) directly also works; you just skip the cache. Many modules also have a ...OrThrow variant (product.findOrThrow) that turns a missing resource (404 or 410) into a not-found page. See Errors, timeouts and retries.

In a component

Use the module's queries with TanStack Query. It refetches, deduplicates and cancels for you:

app/components/CategoryProducts.tsx
import { useQuery } from '@tanstack/react-query';
import { product } from '@salla.sa/twilight-theme-engine/api/product';

export function CategoryProducts({ categoryId }: { categoryId?: number }) {
  const { data, isError } = useQuery({
    ...product.queries.list({ source: 'categories', sourceValue: [categoryId!] }),
    enabled: categoryId !== undefined,
  });

  if (isError) return <p>Could not load products.</p>;
  return (
    <ul>
      {data?.items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Spread the options so you can add your own (enabled, select, staleTime). Use useSuspenseQuery the same way when a parent <Suspense> boundary should show the loading state: data is then never undefined, and a failure goes to the nearest error boundary.

After something changes, refresh the cached copy with the module's own key:

queryClient.invalidateQueries({ queryKey: cart.queries.detail(cartId).queryKey });

Paged lists

Lists use cursors. A page is { items, next }: next is the cursor of the following page, or null on the last one. Treat the cursor as an opaque string.

const first = await product.list({ source: 'latest', perPage: 16 });
if (first.next) {
  const second = await product.list({ source: 'latest', cursor: first.next });
}

Not every module pages the same way. wishlist, wallet and brand.list take a page number, and order.list returns { data, cursor }. Each module page says which.

An endpoint with no module

Use api directly. It already has the base URL, so pass relative paths:

import { api } from '@salla.sa/twilight-theme-engine/api/client';

// The paths are illustrative; use your own endpoint.
const { data } = await api.get('cart/42').json<{ data: Cart }>();

await api.post('some/endpoint', { json: { name: 'value' } });

const res = await api.get('products', {
  searchParams: { source: 'latest', per_page: 8 },
  headers: { 'x-trace': 'home' },   // merged with the client's own headers
  timeout: 3000,                    // overrides the 8 s default for this call
  signal,                           // an AbortSignal
}).json();

.json<T>() parses the body into a typed result. If you use the endpoint in more than one place, wrap it in a module.

Server and browser

The same code runs in both places. On the server the client reads the token from the request cookie; in the browser from the cookie or the Salla SDK storage. You do not branch on it. Keep browser-only code (window, localStorage) out of anything a loader or queryFn runs, because that code also runs on the server.

Why every request goes through the client

A bare fetch looks fine:

app/lib/bad-example.ts
const res = await fetch('https://api.salla.dev/store/v1/products?source=latest');
const { data } = await res.json();

It works in a demo store, for an anonymous customer, in one language. But the request does not know which store it is for, which language the page uses, who the customer is or which branch they picked. The API still answers, so nothing throws. You just get the wrong data:

  • Wrong or missing store. You get an error, or data for a different store.
  • Wrong language. Names and descriptions come back in the API default.
  • Guest instead of the customer. Orders and wishlist fail with token_not_provided, or show guest data to a signed-in customer.
  • Wrong branch. Stock and prices are resolved per branch, so you see the market, not the branch the customer chose.
  • No timeout or retries. A slow backend hangs the render instead of failing after 8 seconds.

The same call through the client:

app/lib/good-example.ts
import { api } from '@salla.sa/twilight-theme-engine/api/client';

const { data } = await api
  .get('products', { searchParams: { source: 'latest' } })
  .json<{ data: unknown }>();
ConcernBare fetchapi.get(...)
Base URLYou repeat itSet once, relative paths
StoreYou find and send itSent automatically, overridable per request
LanguageMissingaccept-language set to the current locale
CustomerYou read the token on both server and browserAuthorization: Bearer added when a token exists
Branch and marketMissings-scope-* headers when a scope is active
Theme versionMissingVersion header when one applies
CurrencyMissingcurrency header when the customer chose another currency
TimeoutNone8 seconds (5 for cdn)
RetriesNoneSafe methods retried twice with backoff
ErrorsCheck res.ok yourselfNon-2xx throws an HTTPError

What the client does not do

  • No caching. Caching and refetching are TanStack Query's job. See Loading data.
  • No query keys. If a response varies by scope, locale or version, put those in the key yourself. The engine's modules already do this where it matters; each module page lists its keys.
  • One base URL. It is https://api.salla.dev/store/v1 unless VITE_API_URL names another backend; see Base URL.
  • Not for third-party URLs. Use whatever you like there, and never send the client's headers to a third party.

Next

On this page