Write your own endpoint
Build a theme-side API module on top of the shared client, with a typed function and a query whose cache key is correct.
Use this when the store API has an endpoint the engine has no module for. You write one file in your theme, in the same shape as the built-in modules. The example fetches a list of "gift cards"; the path and types are placeholders for your own endpoint.
Framework: TanStack Start
The loader in step 3 is TanStack Start code, the default and currently supported framework. See what depends on the framework.
Write the typed function
Build on api, so headers, locale, token and scope come for free:
import { api } from '@salla.sa/twilight-theme-engine/api/client';
import type { PaginatedResult } from '@salla.sa/twilight-theme-engine/api/types';
export interface GiftCard { id: number; name: string; price: number }
export interface GiftCardsParams { cursor?: string | null; signal?: AbortSignal }
async function list({ cursor, signal }: GiftCardsParams = {}): Promise<PaginatedResult<GiftCard>> {
const searchParams = new URLSearchParams();
if (cursor) searchParams.set('cursor', cursor);
const res = await api
.get('gift-cards', { searchParams, signal })
.json<{ data: GiftCard[] | null; cursor?: { next?: string | null } }>();
return { items: res.data ?? [], next: res.cursor?.next ?? null };
}Add queryOptions with a correct key
The key decides when a cached answer may be reused. The API answers differently per language, so put the locale in the key. If your data also varies by the customer's branch, add the scope too (see Authentication and scope).
import { queryOptions } from '@tanstack/react-query';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
export const giftCards = {
list,
queries: {
list: (params: Omit<GiftCardsParams, 'signal'> = {}) =>
queryOptions({
queryKey: ['gift-cards', 'list', params, getTwilightContext().locale],
queryFn: ({ signal }) => list({ ...params, signal }),
}),
},
};Prefetch in the loader, read in the component
import { useSuspenseQuery } from '@tanstack/react-query';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import { giftCards } from '../api/gift-cards';
loader: () => getTwilightContext().queryClient.ensureQueryData(giftCards.queries.list()),
function GiftCards() {
const { data } = useSuspenseQuery(giftCards.queries.list());
return data.items.map((card) => <p key={card.id}>{card.name}</p>);
}The loader fills the cache on the server. The component reads the same key, so the page renders with data and the browser does not fetch it again. To put this on its own URL, see Add a page.
Traps
- Bare
fetchskips the token, locale, store identifier and scope, and has no timeout or retries. It may work while you are signed out and fail for a real customer. - A key without locale and scope serves one language's or branch's data to another. The built-in
product.queries.listincludes the scope for this reason. - Browser-only code in
queryFn(window,localStorage) crashes the server render. - Forgetting
signalleaves cancelled queries running. Pass it through.
Next
- Errors, timeouts and retries: errors from your module behave like any other.
- Modules: read a built-in module's page to copy its conventions.