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

PatternExample
/{-$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

NameTypeDefaultDescription
source'latest' | 'sales' | 'offers' | 'reorder''latest'The list to show. Any other value shows the latest products.
sortstringomittedOne of ourSuggest, bestSell, topRated, priceFromLowToTop, priceFromTopToLow. The loader uses ourSuggest when absent.
pagenumberomittedKept 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
Route
/{-$locale}/products
This address becomes
  • locale = ar
  • path = /products
  • search.source = offers

Route file

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

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, 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.

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

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.

sourceTitle (translation key)page.slug
latestblocks.home.latest_productsproduct.index.latest
salescommon.titles.most_salesproduct.index.sales
offerscommon.titles.discountsproduct.index.offers
reorderpages.orders.repurchaseproduct.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

  • 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

On this page