Fork a built-in page
Take over one engine page: keep its loader and head, add data with extend, and render a component of your own.
Goal: the product page draws something the engine's page does not (here, more products from the same brand) while keeping the engine's data loading, SEO tags and skeleton.
Mechanism: every built-in page is a route module, an object with three separate parts: loader (fetches the data), head (the title and meta tags) and Component (draws the page). Taking over a page means owning its route file: you keep calling Product.loader and withHead(Product), and change only what you need. The loader's second argument, extend, adds fields to the engine's data instead of replacing it.
Reach for this only when a hook slot cannot do the job: once the route file is yours, fixes the engine makes to that page's wiring no longer reach it.
1. Try it
// app/routes.ts
import { route } from '@tanstack/virtual-file-routes';
export const routes = [route('/$slug/p{$id}', 'product-custom.tsx')];
// app/routes/product-custom.tsx (never start it with "// @auto-generated")
import { createFileRoute } from '@tanstack/react-router';
import { Product, type ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';
import { useMoney } from '@salla.sa/twilight-theme-engine/hooks/useMoney';
import { ProductDetailSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
type ProductData = ProductPageProps & { deliveryNote?: string };
export const Route = createFileRoute('/{-$locale}/$slug/p{$id}')({
loader: ({ params }): Promise<ProductData> =>
Product.loader({ params: { id: params.id }, locale: params.locale }, () => ({
deliveryNote: 'Delivered in 2 to 4 days',
})) as Promise<ProductData>,
head: withHead(Product),
pendingComponent: () => <ProductDetailSkeleton />,
component: ProductCustom,
});
function ProductCustom() {
const { product, deliveryNote }: ProductData = Route.useLoaderData();
const { format } = useMoney();
return (
<article className="container">
<h1>{product.name}</h1>
<p>{format(product.price)}</p>
{deliveryNote && <small>{deliveryNote}</small>}
</article>
);
}
2. Write your route file first
Start from a copy of the generated file: it already has the right wiring for this page (params, skeleton, head). Then keep the loader and head, and draw your own component.
import { createFileRoute } from '@tanstack/react-router';
import { product as productApi } from '@salla.sa/twilight-theme-engine/api/product';
import { ProductCard } from '@salla.sa/twilight-theme-engine/components/product';
import { Product, 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';
import type { Product as ProductItem } from '@salla.sa/twilight-theme-engine/types';
// The engine's data, plus the field extend adds.
type ProductData = ProductPageProps & { sameBrand: ProductItem[] };
export const Route = createFileRoute('/{-$locale}/$slug/p{$id}')({
loader: ({ params }): Promise<ProductData> =>
Product.loader(
{ params: { id: params.id }, locale: params.locale },
// extend: runs after the engine's loader; what it returns is merged into the data.
async ({ product }) => {
if (!product.brand?.id) return { sameBrand: [] };
const list = await productApi.list({
source: 'brands',
sourceValue: [product.brand.id],
perPage: 5,
});
return { sameBrand: list.items.filter((item) => item.id !== product.id).slice(0, 4) };
}
) as Promise<ProductData>,
head: withHead(Product), // the engine's title, description, share tags and JSON-LD, unchanged
pendingComponent: () => <ProductDetailSkeleton />,
component: ProductWithBrand,
});
function ProductWithBrand() {
const data: ProductData = Route.useLoaderData();
return (
<>
<Product.Component {...data} />
{data.sameBrand.length > 0 && (
<section className="container py-8">
<h2 className="mb-4 text-lg font-bold">{data.product.brand?.name}</h2>
<div className="s-products-list-wrapper s-products-list-vertical-cards">
{data.sameBrand.map((item) => (
<ProductCard key={item.id} product={item} />
))}
</div>
</section>
)}
</>
);
}
In engine terms
Product.loader(ctx, extend) (src/routes/product/index.tsx) awaits productLoader({ params }), then await extend(data, ctx), and returns { ...data, ...extra }: a shallow merge, so returning a key the engine already uses (product, page) replaces the engine's value. withHead(Product) (src/tanstack/head.ts) calls Product.head(getTwilightContext(), loaderData) and converts the descriptor into TanStack meta, links and scripts. The data you return is serialised into the HTML for hydration, so keep it plain JSON.
3. Point the path at your file
Two ways, pick one:
- A new file (clearer): list the engine's path in
app/routes.tswith a file name of yours, then delete the generatedapp/routes/$slug.p$id.tsx. While your route owns the path, the engine does not write that file again. - Edit in place: open the generated
app/routes/$slug.p$id.tsxand delete its first line,// @auto-generated. The engine only rewrites files that start with that line. Noapp/routes.tschange is needed.
import { route } from '@tanstack/virtual-file-routes';
// The engine's product path, pointed at a file of yours: your route replaces the built-in one.
export const routes = [route('/$slug/p{$id}', 'product-custom.tsx')];
How each module treats extend
The rules differ from page to page. Read from the engine source:
| Route module | extend | Second argument |
|---|---|---|
| Home, ProductListing | Not accepted: the loader takes one argument | — |
| Cart | Called, not awaited: a returned promise is dropped | { params: {} } |
| Product, PageSingle, ThankYou, BlogSingle, BlogCategoryRoute, BlogTagRoute, BlogAuthorRoute, OrderSingle | Awaited | the loader context you passed: { params, locale } |
| Blog, Brands, Loyalty, Testimonials, Profile, Settings, Wallet, Notifications, Wishlist | Awaited | { params: {} } |
| Orders | Awaited, and called twice per load | { params: {} } |
type CartData = CartPageProps & { freeShippingFrom: number };
// Cart's extend is called without await: return the object itself, never a promise.
loader: ({ params }): Promise<CartData> =>
Cart.loader({ locale: params.locale }, () => ({ freeShippingFrom: 200 })) as Promise<CartData>,
Traps
Go deeper: route modules, Forking a built-in page, Product, Cart, withHead, and the built-in pages running live on Built-in pages.