Products
The latest products, best sellers, offers or the customer's order-again list, picked by `?source=`.
Framework: TanStack Start
This page is written for TanStack Start, the default and currently supported framework. See what depends on the framework.
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
| Pattern | Example |
|---|---|
/{-$locale}/products | /products?source=offers · /ar/products?source=latest |
Optional param: locale (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.
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
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.
/ar/products?source=offersOpen/{-$locale}/products- locale = ar
- path = /products
- search.source = offers
Route file
The engine writes this file into its own .twilight/ folder, not into app/routes/:
// @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, category, tag and 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.
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
This makes the offers the default list and keeps the customer's order-again list out of search engines.
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
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.
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
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
reorderfor a guest. The loader throwsunauthorized()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 dropspage; the loader re-runs. - Request failure. The error page is shown, not the 404 page.