Route modules
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.
import { Cart, CartPageProps } from '@salla.sa/twilight-theme-engine/routes/cart';In plain words
A storefront page has three jobs: get its data, describe itself to search engines and link previews, and draw itself. For each built-in page the engine packs those jobs into one plain object, a route module:
loader: an async function that fetches what the page needs (a product, the blog articles) and returns it as a plain object.head: a function that turns that object into the page's<title>, description and share tags.Component: a React component, a function that returns what appears on screen. It receives the loader's object as its props, its input.
Your theme does not write these. Whenever the dev server or a build starts, the twilightReact() plugin writes one short route file per page into app/routes/, connecting the module to the router. Open the pages themselves on Built-in pages.
Signature
const Cart: {
readonly id: 'cart'; // a RouteId value
readonly loader: (
ctx?: { locale?: string },
extend?: (data: CartPageProps, ctx: { params: {} }) => Record<string, unknown> | Promise<Record<string, unknown>>
) => Promise<CartPageProps>;
readonly head: (ctx: TwilightContext, data: CartPageProps) => HeadDescriptor;
readonly Component: (props: CartPageProps) => JSX.Element;
};
// Every module has this shape. What differs from page to page:
// - the loader's first argument: { locale }, { params: { id }, locale }, { search, locale }…
// - extend: extra fields merged into the data (all modules except Home and ProductListing)
// - Component: React.lazy(…) except for Home, Product, ProductListing and CartTry it live
import { createFileRoute } from '@tanstack/react-router';
import { PageSingle } from '@salla.sa/twilight-theme-engine/routes/page';
import type { PageSingleProps } from '@salla.sa/twilight-theme-engine/routes/page';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
// app/routes/$slug.page-$id.tsx, as the plugin generates it (without its first line)
export const Route = createFileRoute('/{-$locale}/$slug/page-{$id}')({
// 1. loader: fetch the page, return the Component's props
loader: ({ params }): Promise<PageSingleProps> =>
PageSingle.loader({ params: { id: params.id }, locale: params.locale }),
// 2. head: title, description, canonical and Open Graph tags from the same data
head: withHead(PageSingle),
// 3. component: draw the page from the loader data
component: PageSingleComponent,
});
function PageSingleComponent() {
const data: PageSingleProps = Route.useLoaderData();
return <PageSingle.Component {...data} />;
}
Example
import { createFileRoute } from '@tanstack/react-router';
import { Cart } from '@salla.sa/twilight-theme-engine/routes/cart';
import type { CartPageProps } from '@salla.sa/twilight-theme-engine/routes/cart';
import { CartSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';
export const Route = createFileRoute('/{-$locale}/cart')({
loader: ({ params }): Promise<CartPageProps> => Cart.loader({ locale: params.locale }),
head: withHead(Cart),
pendingComponent: () => <CartSkeleton />,
component: CartComponent,
});
function CartComponent() {
const data: CartPageProps = Route.useLoaderData();
return <Cart.Component {...data} />;
}
How it behaves
A generated route file is wiring only.
loadercallsModule.loaderwith the URL params,head: withHead(Module)runsModule.head(context, loaderData)and converts the result into TanStackmeta,linksandscripts,pendingComponentis the skeleton shown during a client navigation, and the component spreadsRoute.useLoaderData()intoModule.Component.Generated files start with the line
// @auto-generated(left out of the example above). On every dev start and build the plugin rewrites a route's file when it starts with that line and differs from what the plugin would generate.Loaders run on the server for the first page load and in the browser on client navigations. They read the query client, the i18n instance, the auth token and the current location from the engine's context (
getTwilightContext()), which the router fills in for each request. Call them from route loaders; a component reads the same data through theapi/*queries instead.The engine's
createRoutersetsdefaultStaleTime: Infinity, so going back to an address the router already loaded reuses that loader data instead of calling the loader again (until the match is garbage-collected orrouter.invalidate()runs). Search values listed inloaderDeps, such as?sort=, make a separate match and do run the loader.idis the page'sRouteIdvalue ('cart','product.single','blog.index'…). Most loaders also put it inpage.slug; the account loaders use short slugs such as'profile'instead.Loader data is serialised into the HTML for hydration, so return plain data: no functions, no promises, no class instances.
Most
Components areReact.lazy: the page's code downloads the first time it renders, which suspends. Inside a route the router's Suspense boundary handles that; anywhere else, wrap the component in<Suspense>. Home, Product, ProductListing and Cart are not lazy.The shared shape is typed
RouteModule<TData, TParams, TProps>, a type exported only by the/routesbarrel. The modules are plainas constobjects, not declared with it. See the /routes barrel.
Gotchas
Copying a generated file keeps its first line,
// @auto-generated. The next dev start or build overwrites your copy (generateRouteFilesin packages/theme-engine/src/vite/adapters/tanstack.adapter.ts). Delete that line in every route file you own.headtakes the router context first and the loader data second, and returns aHeadDescriptor, not TanStack'smetaarray. docs/03-routing-system.md writeshead: ({ loaderData }) => Product.head(loaderData)andProduct.head(loaderData, extendFn): the data lands where the context goes,dataisundefined, and the result is not a TanStack head. Usehead: withHead(Module), orwithHead(Module, extendFn).docs/03-routing-system.md says all route modules are exported from
@salla.sa/twilight-theme-engine/routes. They are not: that barrel holds shared types, locale helpers and one redirect loader. Import each module from its own path (/routes/cart,/routes/blog…), as the generated files do.
Related
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.
PageSingleA merchant's content page, such as About us or Terms: loads it by id and shows its HTML and comments.
@salla.sa/twilight-theme-engine/routesA lightweight index of shared route types, locale helpers and one redirect loader; route modules themselves live only on the granular subpaths.
RouteIdThe named list of every engine page id, so code compares with RouteId.CART instead of typing the string "cart".
notFound, redirect, unauthorizedThrow helpers for route loaders: stop and show the not-found page, send the visitor to another address, or refuse a guest.