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

Fork a built-in page

Advanced10 min

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

A forked product route: the engine's loader and head, with a field added by extend and a component of the theme's own.Try this: type a title suffix and watch the <title> line change; clear the delivery note and its line disappears from both the page and the code.
Storefront canvas · ar · RTL
Runs in the browser…
Controls
Returned by Product.loader's extend argument and merged into the data.
What a theme writes
// 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.

app/routes/product-custom.tsx
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.ts with a file name of yours, then delete the generated app/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.tsx and delete its first line, // @auto-generated. The engine only rewrites files that start with that line. No app/routes.ts change is needed.
app/routes.ts
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 moduleextendSecond argument
Home, ProductListingNot accepted: the loader takes one argument
CartCalled, not awaited: a returned promise is dropped{ params: {} }
Product, PageSingle, ThankYou, BlogSingle, BlogCategoryRoute, BlogTagRoute, BlogAuthorRoute, OrderSingleAwaitedthe loader context you passed: { params, locale }
Blog, Brands, Loyalty, Testimonials, Profile, Settings, Wallet, Notifications, WishlistAwaited{ params: {} }
OrdersAwaited, and called twice per load{ params: {} }
app/routes/cart-custom.tsx (excerpt)
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.