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

Forking a built-in page

route-moduleBeginnerserverbrowserlive demo

Take 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.

import { Product, ProductPageProps } from '@salla.sa/twilight-theme-engine/routes/product';

In plain words

When a hook slot is not enough and a page must look different, you still do not rewrite it. A route module's three parts are separate, so you keep the ones that work and replace the rest:

  1. In app/routes.ts, tell the build which file now handles the path.
  2. Write that file like the generated one: call the module's loader for the data and withHead(Module) for the tags.
  3. Render your own component, or the engine's Component with your additions around it.

The loader's second argument, extend, is a function that returns extra fields to add to the engine's data, without replacing it.

Compare your fork with the original on Built-in pages.

Signature

// app/routes.ts: a path the engine already has replaces its route; a new path adds a page
export const routes = [route('/$slug/p{$id}', 'product-custom.tsx')];

// Module loaders (all but Home and ProductListing) accept extend:
Module.loader(
  ctx,
  extend?: (data: TData, ctx) => Record<string, unknown> | Promise<Record<string, unknown>>
): Promise<TData>   // resolves to { ...data, ...(await extend(data, ctx)) }

// withHead (from /tanstack) accepts one too:
withHead(Module, extend?: (result: HeadDescriptor, ctx: TwilightContext, data: TData) => HeadDescriptor)

Try it live

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>
  );
}

Example

app/routes/product-custom.tsx
// app/routes.ts:
//   import { route } from '@tanstack/virtual-file-routes';
//   export const routes = [route('/$slug/p{$id}', 'product-custom.tsx')];

import { createFileRoute } from '@tanstack/react-router';
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';

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, (result) => ({ ...result, title: `${result.title} | Official store` })),
  pendingComponent: () => <ProductDetailSkeleton />,
  component: ProductCustom,
});

function ProductCustom() {
  const data: ProductData = Route.useLoaderData();
  return (
    <>
      <Product.Component {...data} />
      <p className="container">{data.deliveryNote}</p>
    </>
  );
}

How it behaves

  • The plugin reads app/routes.ts in Node when dev or a build starts. Each route(path, file) whose path the engine already has replaces that route; any other path adds one. Inside the file, createFileRoute takes '/{-$locale}' followed by the path.

  • A path starting with /account/ is nested in the account layout (CustomerLayout, with the customer menu), like the built-in account pages.

  • Without routes.ts you can also edit the generated file in place: delete its // @auto-generated first line and the plugin stops rewriting it. A new file name makes the fork easier to find and to undo.

  • extend receives the engine's data and a context: { params: {} } for pages without URL params, the full { params, locale } loader context otherwise. Its result is shallow-merged over the data, so a key with an engine name replaces the engine's value.

  • withHead's extend receives the finished HeadDescriptor, the router context and the loader data, and returns the descriptor to use. It replaces, it does not merge: spread ...result.

  • Copy pendingComponent from the generated file if you want the same skeleton during client navigations. To only add content to a page, a HookSlot handler changes it without owning the route.

Gotchas

  • route('/brands', 'brands.tsx'), reusing the generated file's name while it still starts with // @auto-generated, makes the plugin overwrite it with a fallback that imports a factory named createCustomRoute_brands_tsx from @salla.sa/twilight-theme-engine/routes, which does not exist. Delete the marker first, or use a new file name (generateRouteFileContent, packages/theme-engine/src/vite/adapters/tanstack.adapter.ts).

  • A routes.ts entry whose file does not exist yet gets that same broken fallback written for it. Write the route file first, then add the entry.

  • The override is matched by the exact path string, and the engine's paths start with a slash. route('cart', 'custom-cart.tsx') does not replace /cart: both routes are kept and claim the same address. Write route('/cart', …), spelled exactly like the built-in path ('/$slug/p{$id}', not '/$slug/p$id').

  • Fields added by extend exist at runtime but not in the type: every module loader resolves to its own props type (as ProductPageProps in packages/theme-engine/src/routes/product/index.tsx). Declare the combined type and cast, as the example does, or Route.useLoaderData() will not know them.

  • Cart.loader silently drops an extend that returns a promise (extra instanceof Promise ? {} : extra), and Orders.loader runs extend twice. Home.loader and ProductListing.loader take no extend at all: call homeLoader or productListLoader in your own async loader and spread the result.

  • app/routes.ts is evaluated outside Vite. Only route(...) and index(...) entries are read (layout() and children are ignored), and an import that fails to load makes the plugin warn and read no routes at all. Import nothing but @tanstack/virtual-file-routes there.

Related

Source and docs