Brands
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
Loading brands…
// 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
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
brandsLoaderreads the grouped brands from the API and re-orders the letter keys withlocaleCompare, in Arabic when the request's locale isarand in English otherwise. The title is thecommon.titles.brandstranslation, orBrands.headsets the title and hreflang alternates only.BrandsPagemakes 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 engineLink, and renders the slotsbrands:index.items.startandbrands:index.items.end. With no brands it showsNoContent.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
BrandsPagein<Suspense>.
Gotchas
The type says
/brandsanswers 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).brandsLoaderthen keys the page by array index ('0','1'…) andBrandsPagecalls.mapon a single brand, so the page throwsbrandGroup.map is not a function. Group the brands yourself in the loader'sextend:Object.values(data.brands).flat()gives aBrand[]from either shape, and the returnedbrandsreplaces the engine's. The demo's code panel has the full route.There is no single-brand module.
/brands/$idisProductListingwith sourcebrands, and/$slug/brand-{$id}only redirects there (see SEO redirects).
Related
Take over one built-in page: point its path at your own route file, reuse the module's loader and head, and swap or wrap its Component.
ProductListingOne module behind every product grid: categories, search, tags, brand pages, and the latest, best-selling and offers lists.
slugBrandRedirectLoader, pendingOrdersRedirectLoaderLoaders that render nothing and redirect: old /<slug>/brand-<id> links to the brand's product list, and /pending-orders to the orders list.
brandReads the store's brands, or a single brand, for brand pages and brand-filtered product lists.
Source and docs
- Engine source:
packages/theme-engine/src/routes/brands/index.tsx