# Products (https://docs.salla.dev/twilight/react/routes/products)



<Callout title="Framework: TanStack Start">
  This page is written for TanStack Start, the default and currently supported framework. [See what depends on the framework](https://docs.salla.dev/twilight/react/start/frameworks).
</Callout>

One page shows four product lists, like Twig's products page: the newest products, the best sellers, the products on sale, or the products a signed-in customer ordered before. The `source` search param picks the list. The engine serves this route itself, so your theme has no file for it.

## URL [#url]

| Pattern                | Example                                                  |
| ---------------------- | -------------------------------------------------------- |
| `/{-$locale}/products` | `/products?source=offers` · `/ar/products?source=latest` |

Optional param: `locale` ([locale prefix](https://docs.salla.dev/twilight/react/routes/home#locale-prefix)).

The old Twig addresses still work: `/latest-products`, `/most-sales-products` and `/offers` redirect (301) here with `?source=latest`, `sales` and `offers`, keeping the rest of their query string. See [SEO redirects](https://docs.salla.dev/twilight/react/routes/seo-redirects).

### Search params [#search-params]

| Name     | Type                                           | Default    | Description                                                                                                                                   |
| -------- | ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `source` | `'latest' \| 'sales' \| 'offers' \| 'reorder'` | `'latest'` | The list to show. Any other value shows the latest products.                                                                                  |
| `sort`   | `string`                                       | omitted    | One of `ourSuggest`, `bestSell`, `topRated`, `priceFromLowToTop`, `priceFromTopToLow`. The loader uses `ourSuggest` when absent.              |
| `page`   | `number`                                       | omitted    | Kept only when greater than 1. The API pages only by its own cursor, so the loader ignores a number and the list starts from the first batch. |

### Try it [#try-it]

This is the products page as the engine renders it, running against the demo store. Change the source, switch the language or the width. `reorder` needs a signed-in customer.

<RoutePreview path="/products?source={source}" pattern="/{-$locale}/products" controls="{ source: { label: 'source', default: 'offers', placeholder: 'latest, sales, offers' } }" />

## Route file [#route-file]

The engine writes this file into its own `.twilight/` folder, not into `app/routes/`:

```tsx title="products.tsx (served by the engine)"
// @auto-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';

const SOURCES = ['latest', 'sales', 'offers', 'reorder'] as const;
type Source = (typeof SOURCES)[number];

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

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

`ProductListing` is shared with [search](https://docs.salla.dev/twilight/react/routes/search), [category](https://docs.salla.dev/twilight/react/routes/category), [tag](https://docs.salla.dev/twilight/react/routes/tag) and [brand](https://docs.salla.dev/twilight/react/routes/brand). To restyle all of them at once, register your own page as `product:list-page` rather than replacing each route; see [Replace the product list page](https://docs.salla.dev/twilight/react/recipes/replace-the-product-card#replace-the-whole-product-list-page).

To replace only this route, add `app/routes/products.tsx` without the `// @auto-generated` line. The build then uses your file instead of the engine's.

### A customised version [#a-customised-version]

This makes the offers the default list and keeps the customer's order-again list out of search engines.

```tsx title="app/routes/products.tsx"
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';

const SOURCES = ['latest', 'sales', 'offers', 'reorder'] as const;
type Source = (typeof SOURCES)[number];

export const Route = createFileRoute('/{-$locale}/products')({
  validateSearch: (search: Record<string, unknown>) => {
    const sort = (search.sort as string) || undefined;
    const source = SOURCES.find((s) => s === search.source) ?? 'offers';
    return { source: source as Source, ...(sort ? { sort } : {}) };
  },
  loaderDeps: ({ search }) => ({ source: search.source, sort: search.sort }),
  loader: ({ deps, params }): Promise<ProductListLoaderData> =>
    ProductListing.loader({
      params: { source: deps.source },
      search: { sort: deps.sort },
      locale: params.locale,
    }),
  head: withHead(ProductListing, (head, _ctx, data) =>
    data.source.type === 'reorder' ? { ...head, robots: 'noindex, follow' } : head
  ),
  pendingComponent: () => <ProductListing.Pending hideFilters />,
  component: ListComponent,
});

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

## What it loads [#what-it-loads]

`ProductListLoaderData` with `source: { type }`, `products`, `pagination: { next }` and `query: { sort, filters: false }`, so there is no filter panel. The first batch loads on the server; more products load in the browser as the customer scrolls. See [Loading data](https://docs.salla.dev/twilight/react/guide/loading-data).

| `source`  | Title (translation key)       | `page.slug`            |
| --------- | ----------------------------- | ---------------------- |
| `latest`  | `blocks.home.latest_products` | `product.index.latest` |
| `sales`   | `common.titles.most_sales`    | `product.index.sales`  |
| `offers`  | `common.titles.discounts`     | `product.index.offers` |
| `reorder` | `pages.orders.repurchase`     | `product.index`        |

Breadcrumbs are Home, then this page at `/products?source=…`. `useRouteId()` is `product.index` for every source; tell the lists apart with `source.type` or `page.slug`. The `<body>` class follows the source, as on Twig: `product-index-latest`, `product-index-sales` or `product-index-offers`, and `product-index` for `reorder`.

## Head and SEO [#head-and-seo]

Title is the list's title followed by ` - <store name>`, and description is `Browse <title>`, with matching Open Graph and Twitter tags. Hreflang links point at `/products?source=…` in each store language. There is no canonical tag.

## Edge cases [#edge-cases]

* **`reorder` for a guest.** The loader throws `unauthorized()` before any request: the customer is sent to the home page with `?intended_to=`, the Salla SDK opens the sign-in form, and they return here after signing in. The list is never served from the cache, because its query key names no customer.
* **Unknown or missing `source`.** The latest products.
* **No products.** The list shows its empty state. There is no 404.
* **Changing the sort.** Navigates with `?sort=` and drops `page`; the loader re-runs.
* **Request failure.** The error page is shown, not the 404 page.

## See also [#see-also]

* [Search](https://docs.salla.dev/twilight/react/routes/search) and [Category](https://docs.salla.dev/twilight/react/routes/category)
* [SEO redirects](https://docs.salla.dev/twilight/react/routes/seo-redirects)
* [Fork a built-in route](https://docs.salla.dev/twilight/react/recipes/fork-a-built-in-route)
* [Routes overview](https://docs.salla.dev/twilight/react/routes)
