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

ProductListing

route-moduleBeginnerserverbrowserlive demo

One module behind every product grid: categories, search, tags, brand pages, and the latest, best-selling and offers lists.

import { ProductListing, productListLoader, ProductListPage, ProductListLoaderData, ProductListLoaderContext, ProductListLoaderParams, ProductListSource } from '@salla.sa/twilight-theme-engine/routes/product-listing';

In plain words

A category page, search results and the offers page all show a grid of products with a sort menu. The engine has one module for all of them, and a source says which products to fetch: categories (with an id), search (with a keyword), tags, brands, latest, sales or offers.

The generated routes set the source for you. The grid loads more products as the shopper scrolls. The latest-products, offers and search pages run on Built-in pages.

Signature

const ProductListing: {
  readonly id: 'product.index';
  readonly loader: typeof productListLoader;   // no extend
  readonly head: (ctx: TwilightContext, data: ProductListLoaderData) => HeadDescriptor;
  readonly Component: typeof ProductListPage;
};

function productListLoader(ctx: ProductListLoaderContext): Promise<ProductListLoaderData>;
function ProductListPage(props: ProductListLoaderData): JSX.Element;

interface ProductListLoaderContext {
  params: ProductListLoaderParams;
  locale?: string;                                          // not used
  search?: { page?: string | number; sort?: string; q?: string };
}

interface ProductListLoaderParams {
  id?: string;
  source?: ProductListSource;
  title?: string;     // latest, sales, offers only
  slug?: string;      // latest, sales, offers only: the breadcrumb URL
  keyword?: string;   // search, when search.q is absent
}

type ProductListSource = 'categories' | 'latest' | 'sales' | 'offers' | 'search' | 'tags' | 'brands';

interface ProductListLoaderData {
  page: ProductListPageMeta;         // { title, slug, id?, breadcrumbs? }
  source: ProductListSourceConfig;   // { type, value?, entity? }
  query: ProductListQuery;           // { sort, filters? }
  products: Product[];
  pagination: RoutePagination;       // only next is set
  filters?: Filter[];                // categories and search
}

Try it live

ProductListing's page component given this store's real products for a static source. Scroll to its end: the next products load from pagination.next.Try this: switch the source to offers. Leave the sort menu alone here: it navigates with ?sort=, which only a listing route forwards to its loader.
Storefront canvas · ar · RTL

Loading products…

Controls
Empty: the loader uses the store translation.
What a theme writes
import { createFileRoute } from '@tanstack/react-router';
import { ProductListing } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import type { ProductListLoaderData } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/latest-products')({
  validateSearch: (search: Record<string, unknown>) => {
    const sort = (search.sort as string) || undefined;
    return sort ? { sort } : {};
  },
  loaderDeps: ({ search }) => ({ sort: search.sort }),
  loader: ({ deps }): Promise<ProductListLoaderData> =>
    ProductListing.loader({
      params: {
        source: 'latest',
      },
      search: { sort: deps.sort },
    }),
  head: withHead(ProductListing),
  component: ProductListingComponent,
});

function ProductListingComponent() {
  const data: ProductListLoaderData = Route.useLoaderData();
  return <ProductListing.Component {...data} />;
}

Example

app/routes/$slug.c$id.tsx (generated)
import { createFileRoute } from '@tanstack/react-router';
import { ProductListing } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import type { ProductListLoaderData } from '@salla.sa/twilight-theme-engine/routes/product-listing';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/$slug/c{$id}')({
  validateSearch: (search: Record<string, unknown>) => {
    const page = Number(search.page) || 1;
    const sort = (search.sort as string) || undefined;
    return { ...(page > 1 ? { page } : {}), ...(sort ? { sort } : {}) };
  },
  loaderDeps: ({ search }) => ({ page: search.page, sort: search.sort }),
  loader: ({ deps, params }): Promise<ProductListLoaderData> =>
    ProductListing.loader({
      params: { id: params.id },
      search: { page: deps.page, sort: deps.sort },
      locale: params.locale,
    }),
  head: withHead(ProductListing),
  component: ProductListingComponent,
});

function ProductListingComponent() {
  const data: ProductListLoaderData = Route.useLoaderData();
  return <ProductListing.Component {...data} />;
}

How it behaves

  • Generated routes that use it: /$slug/c{$id} (categories), /search?q= (search), /tags/$id and /$slug/tag-{$id} (tags), /brands/$id (brands), /latest-products, /most-sales-products and /offers.

  • The source is params.source, or categories when only params.id is given. With neither, the loader throws ProductList loader requires source type; a source it cannot serve (for example categories without an id) throws Unknown source type: ….

  • search.sort defaults to ourSuggest, and search.page is sent to the API as the cursor. The search keyword is search.q, or params.keyword; an empty keyword returns an empty list without a request.

  • Categories and brands fetch the entity and the products together; any failure, or a missing entity, throws notFound(), which shows the not-found page. There is no tag endpoint: a tag's name and URL are read from the tags of the first product in the list, so an empty list, or a first product without that tag, throws notFound() too.

  • Requests go through queryClient.fetchQuery, so a list fetched in the last 60 seconds (the query client's staleTime) is served from the cache.

  • query.filters is true for categories and search. ProductListPage shows the filters sidebar and drawer only when it is true and the store enables product filters (store.settings.product.filters).

  • ProductListPage is not lazy. It renders the slots product:list.start, product:list.items.start, product:list.items.end and product:list.end, a banner on brand pages, and store reviews under category pages when the store enables them. Further products load in the browser from pagination.next as the shopper scrolls; the address does not change.

Gotchas

  • The sort menu navigates to the same address with ?sort=. That re-sorts only when the route forwards sort to the loader through validateSearch and loaderDeps, as the generated category, tag, brand and static-list routes do. The generated /search route forwards only q, so sorting search results changes the address and nothing else.

  • The generated /latest-products, /most-sales-products and /offers routes pass English titles ('Latest Products', 'Most Sales Products', 'Offers'), and a passed title wins over the translation (title || i18n.t(...)). An Arabic store shows an English heading. Leave title out in a route you write.

  • Brand listings report page.slug as brands.index, not brands.single, and the module id is product.index for every source. Tell the pages apart with source.type.

  • docs/03-routing-system.md passes sources such as 'product.index.latest'. Those are route ids: the loader throws Unknown source type. The sources are 'latest', 'sales', 'offers' and the others listed above.

  • ?page= is not a page number here. The loader sends search.page to the API as cursor, and the API's cursors are opaque tokens: /latest-products?page=2 asks for cursor=2, which the API refuses (checked on the demo store: ERR_CRYPTO_INVALID_IV, no products), so the page shows an empty list. The <link rel="next"> the grid writes for crawlers points at exactly such an address. Do not link to listing pages with ?page=.

Related

Source and docs