Forking a built-in page
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:
- In
app/routes.ts, tell the build which file now handles the path. - Write that file like the generated one: call the module's
loaderfor the data andwithHead(Module)for the tags. - Render your own component, or the engine's
Componentwith 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
// 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.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.tsin Node when dev or a build starts. Eachroute(path, file)whose path the engine already has replaces that route; any other path adds one. Inside the file,createFileRoutetakes'/{-$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.tsyou can also edit the generated file in place: delete its// @auto-generatedfirst line and the plugin stops rewriting it. A new file name makes the fork easier to find and to undo.extendreceives 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'sextendreceives the finishedHeadDescriptor, the router context and the loader data, and returns the descriptor to use. It replaces, it does not merge: spread...result.Copy
pendingComponentfrom 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 namedcreateCustomRoute_brands_tsxfrom@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.tsentry 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. Writeroute('/cart', …), spelled exactly like the built-in path ('/$slug/p{$id}', not'/$slug/p$id').Fields added by
extendexist at runtime but not in the type: every module loader resolves to its own props type (as ProductPagePropsin packages/theme-engine/src/routes/product/index.tsx). Declare the combined type and cast, as the example does, orRoute.useLoaderData()will not know them.Cart.loadersilently drops anextendthat returns a promise (extra instanceof Promise ? {} : extra), andOrders.loaderrunsextendtwice.Home.loaderandProductListing.loadertake noextendat all: callhomeLoaderorproductListLoaderin your own async loader and spread the result.app/routes.tsis evaluated outside Vite. Onlyroute(...)andindex(...)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-routesthere.
Related
Every built-in page is an object with a loader that fetches its data, a head that sets its tags, and a Component that draws it.
ProductThe product page: loads one product by id with its breadcrumbs, and gives it full search-engine tags, including schema.org Product data.
BrandsThe A to Z brands directory: every brand logo, grouped by first letter, each linking to that brand's product list.
CartThe cart page, whose loader returns only a title because the visitor's cart id exists in the browser, not on the server.
HookSlotA named empty place in the page that renders every handler registered under its name, plus a spot where Salla apps inject content.