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:

app/api/gift-cards.ts
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).

app/api/gift-cards.ts
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

app/routes/gift-cards.tsx
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 fetch skips 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.list includes the scope for this reason.
  • Browser-only code in queryFn (window, localStorage) crashes the server render.
  • Forgetting signal leaves cancelled queries running. Pass it through.

Next

On this page