Search

Product search results for the keyword in the `q` query parameter.

Framework: TanStack Start

This page is written for TanStack Start, the default and currently supported framework. See what depends on the framework.

The customer sees the products that match their keyword, in the same list layout as a category. Customise this route to show a loading state when the keyword changes, or change the head tags.

URL

PatternExample
/{-$locale}/search/search?q=shirt · /ar/search?q=shirt

Optional param: locale (locale prefix).

Search params

NameTypeDefaultDescription
qstring''The keyword. A missing or non-string value becomes ''.
sortstringnoneThe sort order chosen in the sort menu.
pagenumber1Left out of the URL on page 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

Change the keyword and the language, or switch to a phone width. This is the real search route running against the demo store.

/ar/search?q=lightOpen
Route
/{-$locale}/search
This address becomes
  • locale = ar
  • path = /search
  • search.q = light

Route file

The engine writes this file into its own .twilight/ folder, not into app/routes/. Search has its own template: it reads q, sort and page from the URL.

search.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';

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

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

ProductListing is shared with category, brand, tag and products. To replace only this route, add app/routes/search.tsx without the // @auto-generated line, and the build uses yours. To restyle every product list at once, register a page as product:list-page instead; see Replace the product list page.

A customised version

This keeps the same search params, shows a product-grid skeleton while the loader re-runs, and asks search engines not to index result pages.

app/routes/search.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 { ProductGridSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/search')({
  validateSearch: (search: Record<string, unknown>) => {
    const page = Number(search.page) || 1;
    const sort = (search.sort as string) || undefined;
    return { q: (search.q as string) || '', ...(page > 1 ? { page } : {}), ...(sort ? { sort } : {}) };
  },
  loaderDeps: ({ search }) => ({ q: search.q, page: search.page, sort: search.sort }),
  loader: ({ deps, params }): Promise<ProductListLoaderData> =>
    ProductListing.loader({
      params: { source: 'search' },
      search: { q: deps.q, page: deps.page, sort: deps.sort },
      locale: params.locale,
    }),
  head: withHead(ProductListing, (result) => ({
    ...result,
    robots: 'noindex, follow',
  })),
  pendingComponent: () => <ProductGridSkeleton />,
  component: ListComponent,
});

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

What it loads

ProductListLoaderData, shared by every product list:

FieldDescription
pagetitle (the keyword), slug and breadcrumbs.
source{ type: 'search', value: keyword }.
querysort (default ourSuggest) and filters: true.
productsThe first batch of products.
paginationnext: the cursor for the next batch, or null.
filtersThe filters for the filter panel.

The loader trims q and requests the matching products on the server. More products load in the browser as the customer scrolls. See Loading data.

Head and SEO

Title is the keyword and description is Browse <keyword>, with matching Open Graph and Twitter tags and hreflang links. There is no canonical tag.

Edge cases

  • Empty q. No request is made. The page shows the empty state with the translated "Search Results" title.
  • No matches. The same empty state, titled with the keyword.
  • Sort and page. The route keeps q, sort and page in the URL, so the sort menu works. A reload starts the list from the first batch again, because page is not a cursor.
  • Request failure. There is no 404 for search: a failed request shows the error page.
  • Locale. See locale prefix.

See also

On this page