Product
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
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
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 onlyparams.id; the slug is never read, so-works (the engine's own redirect route builds/ar/-/p<id>).productLoadercallsproduct.findOrThrow(id): any API failure becomes aNotFoundError, which the router's default error component shows as a 404 page.page.parentis the product's category when it has one.Product.loaderignoreslocale(the API language comes from the request) and awaitsextend, which receives the whole loader context.head: the description is the product description with HTML removed, cut to 160 characters;canonicalis the product URL; Open Graph typeproductwith the main image; Twittersummary_large_image; hreflang alternates.The JSON-LD is a schema.org
Productwith anOffer(price, currency, availability, the store as seller). It addsbrandwhen set,aggregateRatingand areviewwhen the rating count is above 0,gtin8for an 8-character GTIN, andmpn.The page component is not lazy and has no named export: it is reachable only as
Product.Component. It renders 11 hook slots, fromproduct:startandproduct:single.form.starttoproduct:related.endandproduct:end.
Gotchas
The JSON-LD's
offers.priceValidUntilis today plus one month, computed each timeheadruns, so the tags change from day to day. Fix the clock before snapshot-testing head output.Without a
productin the data,headreturns the English literalsProductsandBrowse our products, whatever the page language.
Related
Every built-in page is an object with a loader that fetches its data, a head that sets its tags, and a Component that draws it.
Forking a built-in pageTake over one built-in page: point its path at your own route file, reuse the module's loader and head, and swap or wrap its Component.
useProductKeeps a live copy of a product that follows price and stock changes from Salla option pickers, and can reload its details.
ProductDetailsThe information half of a product page: brand, name, rating, price, description, tags, share and wishlist, SKU and stock counters.
Source and docs
- Engine source:
packages/theme-engine/src/routes/product/index.tsx