Skip to content
Twilight React Playground
ثيم رائدaren

Product

route-moduleBeginnerserverbrowserlive demo

The product page: loads one product by id with its breadcrumbs, and gives it full search-engine tags, including schema.org Product data.

import { Product, productLoader, ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';

In plain words

A product address looks like /ar/-/p123456: the number after p is the product id, and the part before it is only there to make links readable.

Product.loader fetches that product and builds its breadcrumb trail (Home › category › product). Product.head turns it into a title, a description, share tags and structured data, a JSON block that search engines read to show prices and ratings. Product.Component draws the gallery, options, price and add-to-cart form.

See it running with a real product on Built-in pages.

Signature

const Product: {
  readonly id: 'product.single';
  readonly loader: (
    ctx: { params: { id: string }; locale?: string },
    extend?: (
      data: ProductPageProps,
      ctx: { params: { id: string } }
    ) => Record<string, unknown> | Promise<Record<string, unknown>>
  ) => Promise<ProductPageProps>;
  readonly head: (ctx: TwilightContext, data: ProductPageProps) => HeadDescriptor;
  readonly Component: (props: ProductPageProps) => JSX.Element;
};

function productLoader({ params }: { params: { id: string } }): Promise<ProductPageProps>;

interface ProductPageProps {
  page: Page;       // { slug: 'product.single', id, title, url, parent, breadcrumbs }
  product: Product;
}

Try it live

The search-engine and share tags the Product route gives a real product from this store: first as the HeadDescriptor Product.head returns, then as the tags withHead hands the router.Try this: open jsonLd in the HeadDescriptor view, then switch to tags and find the same data as a script. A suffix only changes the tags view.
Storefront canvas · en · LTR
Runs in the browser…
Controls
What a theme writes
import { createFileRoute } from '@tanstack/react-router';
import { Product } from '@salla.sa/twilight-theme-engine/routes/product';
import type { ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';
import { ProductDetailSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/$slug/p{$id}')({
  loader: ({ params }): Promise<ProductPageProps> =>
    Product.loader({ params: { id: params.id }, locale: params.locale }),
  head: withHead(Product),
  pendingComponent: () => <ProductDetailSkeleton />,
  component: ProductComponent,
});

function ProductComponent() {
  const data: ProductPageProps = Route.useLoaderData();
  return <Product.Component {...data} />;
}

Example

app/routes/$slug.p$id.tsx (generated)
import { createFileRoute } from '@tanstack/react-router';
import { Product } from '@salla.sa/twilight-theme-engine/routes/product';
import type { ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';
import { ProductDetailSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/$slug/p{$id}')({
  loader: ({ params }): Promise<ProductPageProps> =>
    Product.loader({ params: { id: params.id }, locale: params.locale }),
  head: withHead(Product),
  pendingComponent: () => <ProductDetailSkeleton />,
  component: ProductComponent,
});

function ProductComponent() {
  const data: ProductPageProps = Route.useLoaderData();
  return <Product.Component {...data} />;
}

How it behaves

  • URL: /{-$locale}/$slug/p{$id}. The generated route passes only params.id; the slug is never read, so - works (the engine's own redirect route builds /ar/-/p<id>).

  • productLoader calls product.findOrThrow(id): any API failure becomes a NotFoundError, which the router's default error component shows as a 404 page. page.parent is the product's category when it has one.

  • Product.loader ignores locale (the API language comes from the request) and awaits extend, which receives the whole loader context.

  • head: the description is the product description with HTML removed, cut to 160 characters; canonical is the product URL; Open Graph type product with the main image; Twitter summary_large_image; hreflang alternates.

  • The JSON-LD is a schema.org Product with an Offer (price, currency, availability, the store as seller). It adds brand when set, aggregateRating and a review when the rating count is above 0, gtin8 for an 8-character GTIN, and mpn.

  • The page component is not lazy and has no named export: it is reachable only as Product.Component. It renders 11 hook slots, from product:start and product:single.form.start to product:related.end and product:end.

Gotchas

  • The JSON-LD's offers.priceValidUntil is today plus one month, computed each time head runs, so the tags change from day to day. Fix the clock before snapshot-testing head output.

  • Without a product in the data, head returns the English literals Products and Browse our products, whatever the page language.

Related

Source and docs