Tag page

Products that carry one tag, reachable from both the slug URL and the short /tags URL.

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 carry one tag, after selecting a tag shown on a product. The engine has two routes for it with the same loader and result: the SEO form /$slug/tag-{$id} and the short form /tags/$id. Customise them to change the title or add content above the list; override both if you want the two URLs to look the same. This is the product tag; the blog has its own tag page.

URL

PatternExample
/$slug/tag-{$id}/summer/tag-88
/{locale}/$slug/tag-{$id}/ar/summer/tag-88
/tags/$id/tags/88
/{locale}/tags/$id/en/tags/88

Params

NameTypeExampleDescription
slugstringsummerSlug form only. Not passed to the loader.
idstring88The tag id. Written tag-88 in the slug form, plain 88 in /tags/$id.
localestring | undefinedarOptional (locale prefix).

Search params

NameTypeExampleDescription
sortstringbestSellOptional. The loader uses ourSuggest when absent.
pagenumber2Optional. Values of 1 or less are dropped. The API pages only by its own cursor, so the loader ignores a number and the list starts from the first batch.

Route file

The engine writes two files, one per URL form, into its own .twilight/ folder rather than app/routes/. First $slug.tag-$id.tsx:

$slug.tag-$id.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}/$slug/tag-{$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: { source: 'tags', id: params.id },
      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} />;
}

tags.$id.tsx is the same except for the path:

tags.$id.tsx (served by the engine)
export const Route = createFileRoute('/{-$locale}/tags/$id')({
  // validateSearch, loaderDeps and loader exactly as above
  head: withHead(ProductListing),
  pendingComponent: () => <ProductListing.Pending hideFilters />,
  component: ProductListingComponent,
});

To take a page over, add a file of the same name to app/routes/ without the // @auto-generated line, or write your own file and map the path in app/routes.ts: route('/$slug/tag-{$id}', 'slug-tag-page.tsx') and route('/tags/$id', 'tags-page.tsx'). See Fork a built-in route. 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 adds a suffix to the title and a line above the engine list.

app/routes/tag-page.tsx
import { createFileRoute } from '@tanstack/react-router';
import {
  ProductListing,
  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/tag-{$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: { source: 'tags', id: params.id },
      search: { page: deps.page, sort: deps.sort },
      locale: params.locale,
    }),
  head: withHead(ProductListing, (head, _ctx, data) => ({
    ...head,
    title: `${data.page.title} | Official store`,
  })),
  component: ListComponent,
});

function ListComponent() {
  const data: ProductListLoaderData = Route.useLoaderData();
  return (
    <>
      <p className="container">{data.products.length} products on this page</p>
      <ProductListing.Component {...data} />
    </>
  );
}

What it loads

ProductListLoaderData, the same shape as on the category page. For tags:

  • The loader requests the product list for the tag and takes the tag (id, name, url) from the first product's tags. That tag becomes source.entity.
  • query.filters is false, so there is no filter panel.
  • More products load in the browser as the customer scrolls.

Sort values are ourSuggest, bestSell, topRated, priceFromLowToTop and priceFromTopToLow. The page has the same hook slots as every product list (product:list.*).

Head and SEO

Title is the tag name, description is Browse {name}, canonical and og:url are the tag's own url, with hreflang links for every language. There is no social image.

Edge cases

  • Empty or unknown tag. The tag name comes from the first product, so a tag with no products cannot be shown. The loader throws a not-found error and the not-found page renders.
  • Request failure. Unlike categories and brands, a failed request is not turned into a 404; the error page shows.
  • Two URLs, one list. /tags/88 and /x/tag-88 render the same page. The canonical is the tag's own url from the API, which tells search engines which form to keep.
  • Wrong slug. Ignored; no redirect.

See also

On this page