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

Route modules

route-moduleBeginnerserverbrowserlive demo

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 Cart

Try it live

The three parts of one route module, PageSingle, fed the same data: what the loader returns, the head tags built from it, and the page drawn from it.Try this: add a title suffix and watch only the head tags change; then note that the description keeps the &nbsp; the page itself turns into a space.
Storefront canvas · ar · RTL
Runs in the browser…
Controls
The HTML a merchant writes in the dashboard.
Leave empty to use head as it is.
What a theme writes
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

app/routes/cart.tsx (generated)
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. loader calls Module.loader with the URL params, head: withHead(Module) runs Module.head(context, loaderData) and converts the result into TanStack meta, links and scripts, pendingComponent is the skeleton shown during a client navigation, and the component spreads Route.useLoaderData() into Module.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 the api/* queries instead.

  • The engine's createRouter sets defaultStaleTime: 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 or router.invalidate() runs). Search values listed in loaderDeps, such as ?sort=, make a separate match and do run the loader.

  • id is the page's RouteId value ('cart', 'product.single', 'blog.index'…). Most loaders also put it in page.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 are React.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 /routes barrel. The modules are plain as const objects, 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 (generateRouteFiles in packages/theme-engine/src/vite/adapters/tanstack.adapter.ts). Delete that line in every route file you own.

  • head takes the router context first and the loader data second, and returns a HeadDescriptor, not TanStack's meta array. docs/03-routing-system.md writes head: ({ loaderData }) => Product.head(loaderData) and Product.head(loaderData, extendFn): the data lands where the context goes, data is undefined, and the result is not a TanStack head. Use head: withHead(Module), or withHead(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

Source and docs