Skip to content
Twilight React Playground
ثيم رائدaren

Brands

route-moduleBeginnerserverbrowserlive demo

The A to Z brands directory: every brand logo, grouped by first letter, each linking to that brand's product list.

import { Brands, BrandsPage, brandsLoader, BrandsPageProps, Brand, BrandsGroup } from '@salla.sa/twilight-theme-engine/routes/brands';

In plain words

At /brands the shopper sees every brand the store sells under letter headings, with a letter menu to jump between them. Clicking a logo opens /brands/<id>, which is a product list served by the ProductListing module with source brands, not a page of this module.

Try the page on Built-in pages, and read the first gotcha below before you ship it.

Signature

const Brands: {
  readonly id: 'brands.index';
  readonly loader: (
    ctx?: { locale?: string },
    extend?: (data: BrandsPageProps, ctx: { params: {} }) => Record<string, unknown> | Promise<Record<string, unknown>>
  ) => Promise<BrandsPageProps>;
  readonly head: (ctx: TwilightContext, data: BrandsPageProps) => HeadDescriptor;
  readonly Component: React.LazyExoticComponent<React.MemoExoticComponent<(props: BrandsPageProps) => JSX.Element>>;
};

const BrandsPage: typeof Brands.Component;
function brandsLoader(): Promise<BrandsPageProps>;

interface BrandsPageProps { page: Page; brands: BrandsGroup }
interface BrandsGroup { [letter: string]: Brand[] }

interface Brand {
  id: string;
  name: string;
  url: string;
  logo: string;
  banner?: string | null;
  description: string;
  label: string;
  status: boolean;
  ar_char: string;
  en_char: string;
  channels?: string[];
  metadata?: { title: string; description: string; url: string };
}

Try it live

The engine's Brands page given this store's brands, as the API sends them or grouped by letter first.Try this: turn grouping off: the page throws, because the demo store sends a flat list where BrandsPage expects letters. Then switch the language pill.
Storefront canvas · en · LTR

Loading brands…

Controls
Group by letter in extend
What a theme writes
// app/routes.ts: route('/brands', 'brands-custom.tsx')
import { createFileRoute } from '@tanstack/react-router';
import { Brands, type Brand, type BrandsGroup, type BrandsPageProps } from '@salla.sa/twilight-theme-engine/routes/brands';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

function byLetter(brands: Brand[], locale: string): BrandsGroup {
  const groups: BrandsGroup = {};
  for (const item of brands) {
    const letter = (locale === 'ar' ? item.ar_char : item.en_char) || '#';
    (groups[letter] ??= []).push(item);
  }
  return groups;
}

export const Route = createFileRoute('/{-$locale}/brands')({
  loader: ({ params }): Promise<BrandsPageProps> =>
    // extend's result is merged over the data, so this brands replaces the engine's.
    Brands.loader({ locale: params.locale }, (data) => ({
      brands: byLetter(Object.values(data.brands).flat(), params.locale ?? 'ar'),
    })),
  head: withHead(Brands),
  component: BrandsCustom,
});

function BrandsCustom() {
  const data: BrandsPageProps = Route.useLoaderData();
  return <Brands.Component {...data} />;
}

Example

app/routes/brands.tsx (generated)
import { createFileRoute } from '@tanstack/react-router';
import { Brands } from '@salla.sa/twilight-theme-engine/routes/brands';
import type { BrandsPageProps } from '@salla.sa/twilight-theme-engine/routes/brands';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/brands')({
  loader: ({ params }): Promise<BrandsPageProps> => Brands.loader({ locale: params.locale }),
  head: withHead(Brands),
  component: BrandsComponent,
});

function BrandsComponent() {
  const data: BrandsPageProps = Route.useLoaderData();
  return <Brands.Component {...data} />;
}

How it behaves

  • brandsLoader reads the grouped brands from the API and re-orders the letter keys with localeCompare, in Arabic when the request's locale is ar and in English otherwise. The title is the common.titles.brands translation, or Brands.

  • head sets the title and hreflang alternates only.

  • BrandsPage makes the letter menu sticky after 200px of scrolling, scrolls smoothly to a letter when it is clicked, links each logo to /brands/<id> with the engine Link, and renders the slots brands:index.items.start and brands:index.items.end. With no brands it shows NoContent.

  • To list brands elsewhere (a home block, the footer), read useQuery(brand.queries.list()) from @salla.sa/twilight-theme-engine/api/brands, as the demo does. It returns the API's answer as it is; only the loader sorts the letters.

  • Lazy: outside a route, wrap BrandsPage in <Suspense>.

Gotchas

  • The type says /brands answers with brands grouped by letter (BrandsGroup), but the demo store's API answers with a flat array of brands and a cursor (checked 2026-09-16, with the engine's own request headers). brandsLoader then keys the page by array index ('0', '1'…) and BrandsPage calls .map on a single brand, so the page throws brandGroup.map is not a function. Group the brands yourself in the loader's extend: Object.values(data.brands).flat() gives a Brand[] from either shape, and the returned brands replaces the engine's. The demo's code panel has the full route.

  • There is no single-brand module. /brands/$id is ProductListing with source brands, and /$slug/brand-{$id} only redirects there (see SEO redirects).

Related

Source and docs