Product page
The single product page, with its URL, params, route file and the edge cases around unknown products.
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 one product: gallery, details, the add-to-cart form, reviews and related products. It is the most important page of most stores and has the richest SEO data of any built-in route. Customise this route to add data to the loader, change the title, or wrap the page; to change only how a product looks in lists, replace the product card.
URL
| Pattern | Example |
|---|---|
/$slug/p{$id} | /red-shirt/p123 |
/{locale}/$slug/p{$id} | /ar/red-shirt/p123 |
Params
| Name | Type | Example | Description |
|---|---|---|---|
slug | string | red-shirt | For readable links and SEO. The loader never reads it, so a wrong slug still shows the product. |
id | string | 123 | The product id. In the URL it follows the letter p (p123); the router removes the p. |
locale | string | undefined | ar | Optional (locale prefix). |
There are no search params.
Try it
This is a product as the engine renders it, running against the demo store. Switch the language or the width.
/ar/x/p677709975Open/{locale}/$slug/p{$id}- locale = ar
- path = /x/p677709975
Route file
// @auto-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} />;
}To take the page over, write your own file (without the // @auto-generated line) and point the same path at it from app/routes.ts: route('/$slug/p{$id}', 'product-single.tsx'). The generated file stays but is no longer used. The fork recipe walks through it.
A customised version
This keeps the engine loader, merges in one extra field, extends the head, and renders your element around the engine page. Product.loader takes an optional second argument that receives the loaded data and returns fields to merge in.
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 & { badge: string }> =>
Product.loader({ params: { id: params.id }, locale: params.locale }, (data) => ({
badge: data.product.is_available ? 'In stock' : 'Sold out',
})) as Promise<ProductPageProps & { badge: string }>,
head: withHead(Product, (head, _ctx, data) => ({
...head,
title: `${data.product.name} | Official store`,
})),
pendingComponent: () => <ProductDetailSkeleton />,
component: ProductComponent,
});
function ProductComponent() {
const { badge, ...data } = Route.useLoaderData();
return (
// key remounts the page when you move from one product to another
<div key={data.product.id}>
<p className="container">{badge}</p>
<Product.Component {...data} />
</div>
);
}What it loads
ProductPageProps:
| Field | Type | Description |
|---|---|---|
product | Product | The full product record. |
page | Page | id, title, url, parent (the category) and breadcrumbs (Home, category, product). |
It runs on the server for the first request and in the browser for later navigations. Related products, reviews, the offer banner and testimonials are not part of it: they load in the browser when scrolled into view. See Loading data, and useProduct to keep the product in sync after variant or stock changes.
Hook slots, in page order: product:start, product:details.start, product:single.description.start, product:single.description, product:details.end, product:single.form.start, product:single.form.end, product:single.description.end, product:related.start, product:related.end, product:end. The three product:single.description* slots render once each with { product } as context.
Head and SEO
Product.head builds, from the loaded product:
- Title is the product name; description is the product description without HTML, cut to 160 characters.
- Canonical and
og:urlareproduct.url, so the slug in the address bar never defines the canonical URL. - Open Graph (
product) and Twitter cards with the main image. - A
ProductJSON-LD block: SKU, images, anOffer(price, currency, availability) and, when present, brand, rating,gtin8(only 8-digit GTINs) andmpn. - Hreflang links for every store language.
- When the store has quick buy enabled, the quick-checkout script is added up front, because the Buy Now button is above the fold.
See SEO and head.
Edge cases
- Unknown product id. Any API failure becomes a 404 error page. It is the plain error page, not the full not-found page, so the
notfound:*hook slots do not appear. ThrownotFound()from your own loader if you need the full page. - Wrong slug. No redirect:
/anything/p123shows product 123. The canonical tag keeps search engines on one URL. - Short links.
/ar/lvXvzlndoes not match this route; the not-found page resolves it and redirects here (SEO redirects). - Product to product navigation. The route component is reused when only the params change. Set a
key(as above) if your component keeps mount-once state.