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- The client (
api) adds the locale, store, login token and branch to every request. - Modules wrap one area each (
cart,product,orderand so on) as plainasyncfunctions. See all modules. querieson a module are TanStack QueryqueryOptions, ready foruseQueryin 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):
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:
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:
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:
import { api } from '@salla.sa/twilight-theme-engine/api/client';
const { data } = await api
.get('products', { searchParams: { source: 'latest' } })
.json<{ data: unknown }>();| Concern | Bare fetch | api.get(...) |
|---|---|---|
| Base URL | You repeat it | Set once, relative paths |
| Store | You find and send it | Sent automatically, overridable per request |
| Language | Missing | accept-language set to the current locale |
| Customer | You read the token on both server and browser | Authorization: Bearer added when a token exists |
| Branch and market | Missing | s-scope-* headers when a scope is active |
| Theme version | Missing | Version header when one applies |
| Currency | Missing | currency header when the customer chose another currency |
| Timeout | None | 8 seconds (5 for cdn) |
| Retries | None | Safe methods retried twice with backoff |
| Errors | Check res.ok yourself | Non-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/v1unlessVITE_API_URLnames 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
- Client reference: the exports, headers, timeout and retry numbers.
- Authentication and scope: who the request is for.
- Errors, timeouts and retries: handling failure.
- Write your own endpoint: a typed module on top of
api. - Modules: one page per area, with every method.