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

PatternExample
/$slug/p{$id}/red-shirt/p123
/{locale}/$slug/p{$id}/ar/red-shirt/p123

Params

NameTypeExampleDescription
slugstringred-shirtFor readable links and SEO. The loader never reads it, so a wrong slug still shows the product.
idstring123The product id. In the URL it follows the letter p (p123); the router removes the p.
localestring | undefinedarOptional (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
Route
/{locale}/$slug/p{$id}
This address becomes
  • locale = ar
  • path = /x/p677709975

Route file

app/routes/$slug.p$id.tsx
// @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.

app/routes/product-single.tsx
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:

FieldTypeDescription
productProductThe full product record.
pagePageid, 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:url are product.url, so the slug in the address bar never defines the canonical URL.
  • Open Graph (product) and Twitter cards with the main image.
  • A Product JSON-LD block: SKU, images, an Offer (price, currency, availability) and, when present, brand, rating, gtin8 (only 8-digit GTINs) and mpn.
  • 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. Throw notFound() from your own loader if you need the full page.
  • Wrong slug. No redirect: /anything/p123 shows product 123. The canonical tag keeps search engines on one URL.
  • Short links. /ar/lvXvzln does 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.

See also

On this page