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

URLs, routes and pages

Beginner15 min

How an address becomes a page, how a component tells which page it is on, and how to add routes of your own.

A URL is the address of a page: https://your-store.com/ar/cart. A route is a rule that says "addresses shaped like this one show that page". The part after the domain decides everything:

  1. the locale, the first segment (ar), picks the language;
  2. the rest (/cart) is matched against the list of routes;
  3. the matched route's loader fetches what the page needs;
  4. its component draws the page from that data.

From an address to a page

Type a path, or pick one, and follow the four steps. Segments starting with $ in a route are params: placeholders that capture part of the address, such as a product's id.

The last two rows are what your own components read to tell one page from another — the section below uses them.

1. Localeen
2. Route/{-$locale}/$slug/p{$id} (a product)
Params{"slug":"leather-armchair","id":"1303461379","locale":"en"}
Fileapp/routes/$slug.p$id.tsx (generated)
3. LoaderProduct.loader(…) fetches the data
4. Component<Product.Component {...data} /> from @salla.sa/twilight-theme-engine/routes/product
Page iduseRouteId() is "product.single", written as RouteId.PRODUCT_SINGLE
Body class<body class="product-single">

Your theme gets every one of these routes for free: the build writes one small file per route into app/routes/, and each borrows a route module from the engine.

A route module: loader, head, Component

Every built-in page is an object with three parts. Below is a real one, PageSingle, the module behind merchant pages such as a shipping policy, fed the same data three ways.

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

Which page am I on?

A component drawn on many pages — a product card in a grid, the header, a banner — often has to behave differently on one of them. Reading the address is a poor way to tell: /leather-armchair/p1303461379 and /chairs/c1373748323 differ by a single letter. Ask the engine instead. Three questions, three answers.

1. Which kind of page is this?

useRouteId() returns the id of the page on screen: index, cart, product.single. RouteId names every one, so compare with the constant rather than typing the string and your editor completes it for you. The inspector above shows the id of any address, in its Page id row.

The hook subscribes to navigation: when the shopper moves to another page, every component that called it renders again with the new id.

app/components/DeliveryNote.tsx
import { RouteId, useRouteId } from '@salla.sa/twilight-theme-engine';

/** Every page that shows a list of products. */
const LISTINGS: string[] = [
  RouteId.PRODUCT_INDEX,         // a category
  RouteId.SEARCH,                // search results
  RouteId.PRODUCT_INDEX_OFFERS,  // the offers page
];

export function DeliveryNote() {
  const routeId = useRouteId();

  if (routeId === RouteId.PRODUCT_SINGLE) return <p>Ships within 24 hours</p>;
  if (LISTINGS.includes(routeId)) return <p>Free delivery over 200 SAR</p>;
  return null;
}

For the seven most common questions there is a ready-made hook, each one useRouteId() plus a comparison: useIsHome(), useIsProduct(), useIsCart(), useIsSearch(), useIsBlog(), useIsBrands() and useIsCustomer().

app/components/StickyBuyBar.tsx
import { useIsProduct } from '@salla.sa/twilight-theme-engine';

export function StickyBuyBar() {
  // The product page only. A category or a search listing is not a product page.
  const isProduct = useIsProduct();
  if (!isProduct) return null;

  return <div className="sticky-buy-bar"></div>;
}

The id of the page you are reading right now, compared with any constant you pick:

The id of the page you are reading, from useRouteId(), compared with a RouteId constant.Try this: pick CART and read the code: on the store’s cart page that component renders; on this documentation page no constant matches.
Storefront canvas · ar · RTL
Runs in the browser…
Controls
What a theme writes
import type { ReactNode } from 'react';
import { RouteId, useRouteId } from '@salla.sa/twilight-theme-engine';

export function OnlyOn({ children }: { children: ReactNode }) {
  const routeId = useRouteId();
  return routeId === RouteId.INDEX ? <>{children}</> : null;
}

2. Which product or category is it showing?

The id says what kind of page it is, never which one. That is the page record: a built-in loader returns one beside its data, describing the thing on screen. `slug` is the only field every loader setstitle, id, url, parent and breadcrumbs are each filled in by the loaders that have them, and the home page's record is { slug: 'index' } and nothing else. Check a field before you render it.

app/routes/$slug.p$id.tsx — inside a route
// The loader returned { page, product }, so the page record is already here.
const { page, product } = Route.useLoaderData();

page.slug;         // 'product.single' — on this page, the id useRouteId() returns
page.title;        // 'Leather armchair'
page.id;           // 1303461379 — which product this page is showing
page.url;          // this page's address in the store
page.breadcrumbs;  // Home › Chairs › Leather armchair

// slug is the only one every loader sets. The rest are optional, and a loader
// with nothing to put there leaves them undefined: the home page's record is
// { slug: 'index' } and nothing else, and the cart's is slug plus title.

Reading it there is the safe case: the route component renders again whenever its loader runs, so the record beside it is always this page's.

A component further down the page has no Route to ask and reads the same record from the request context — but that read is a snapshot, not a subscription. Use it for a component the route already re-renders, and for one that must follow the page on its own (a header, a hook slot), listen for the navigation instead: see Track page views.

app/components/CategoryHeading.tsx — anywhere
import { RouteId } from '@salla.sa/twilight-theme-engine';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';

// A snapshot, not a subscription: this reads whatever the last loader stored, and
// nothing re-renders the component when it changes. Fine inside the page the route
// draws, which renders again on every navigation. A header or a hook slot outlives
// that, and must follow the Salla.event 'route::changed' event instead.
export function CategoryHeading() {
  const page = getTwilightContext().page;
  if (page?.slug !== RouteId.PRODUCT_INDEX) return null;

  // On a category listing, page.title is the category's own name.
  return <h2>More from {page.title}</h2>;
}

3. Only the styling changes?

Then none of the above is needed. TwilightProvider keeps a class for the current page on <body>index, cart, product-single, product-index, shown in the inspector's Body class row — so a stylesheet can single a page out on its own.

app/styles/theme.css
body.product-single .promo-banner {
  display: none;
}

body.index .promo-banner {
  margin-block: 2rem;
}

All three, in one product card

The engine's ProductCard, with a line under it naming the page it is being drawn on. Nothing is passed in to tell it where it is — that is the point: the same card can sit in the home page's slider, in a category grid and in the search results and still know which it is.

Loading a product from the demo store…

Here the card sits on a route this playground added, so no RouteId names it and useRouteId() answers with the router's own id, /{-$locale}/playground/$section/$slug — exactly what the inspector predicts for a page of your own. On the first load of a page it is still the empty string, because the id is written when the route resolves: move to another page of the docs and back, and watch the line fill in.

The same component in a store reads index on the home page and product.index in a category, where page.title is the category's own name.

app/components/product/PageAwareProductCard.tsx
import { RouteId, useRouteId } from '@salla.sa/twilight-theme-engine';
import { ProductCard } from '@salla.sa/twilight-theme-engine/components/product';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import type { Product } from '@salla.sa/twilight-theme-engine/types';

/** What to call each page a product card can be drawn on. */
const PAGE_NAME: Record<string, string> = {
  [RouteId.INDEX]: 'the home page',
  [RouteId.PRODUCT_INDEX]: 'a category',
  [RouteId.PRODUCT_INDEX_OFFERS]: 'the offers page',
  [RouteId.PRODUCT_INDEX_LATEST]: 'the latest products',
  [RouteId.SEARCH]: 'the search results',
  [RouteId.PRODUCT_SINGLE]: 'a product page',
  [RouteId.CART]: 'the cart',
};

/**
 * The engine's product card, plus a line naming the page it is drawn on.
 *
 * Nothing is passed in to tell it where it is. `useRouteId()` returns the id of the
 * page on screen and subscribes, so the card renders again after every navigation;
 * the request context holds the `page` record that page's loader returned, which
 * names the thing on screen rather than the kind of page.
 */
export function PageAwareProductCard({ product }: { product: Product }) {
  const routeId = useRouteId();
  const page = getTwilightContext().page;

  return (
    <div className="flex flex-col items-start gap-2">
      <ProductCard product={product} />

      {/* Draw something on one kind of page only. */}
      {routeId === RouteId.INDEX && (
        <span className="rounded border border-primary px-2 py-1 text-xs text-primary">
          Pick of the week
        </span>
      )}

      <p className="text-sm text-gray-600" dir="ltr">
        Drawn on {PAGE_NAME[routeId] ?? 'a page this theme added'}. <code>useRouteId()</code> is{' '}
        <code>{routeId || '(not resolved yet)'}</code>
        {page ? (
          <>
            , <code>page.slug</code> is <code>{page.slug}</code> and <code>page.title</code> is{' '}
            <code>{page.title}</code>.
          </>
        ) : (
          <>
            , and this page returned no <code>page</code> record.
          </>
        )}
      </p>
    </div>
  );
}

Adding your own page

Your own routes are listed in app/routes.ts. A path the engine does not have adds a page; a path it does have, such as /cart, replaces the built-in page. Write the route file first, then add the line.

app/routes/faq.tsx
// app/routes/faq.tsx (written by you: never start it with "// @auto-generated")
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';

interface FaqData {
  questions: Array<{ q: string; a: string }>;
}

export const Route = createFileRoute('/{-$locale}/faq')({
  // 1. Runs before the page is drawn: on the server for the first visit,
  //    in the browser when the shopper arrives through a link.
  loader: async (): Promise<FaqData> => ({
    questions: [{ q: 'How long is delivery?', a: 'Two to four days.' }],
  }),
  // 2. What goes in <head>
  head: () => ({ meta: [{ title: 'FAQ' }] }),
  // 3. What goes on the page
  component: FaqPage,
});

function FaqPage() {
  const { questions } = Route.useLoaderData();
  const { t } = useTranslation();
  return (
    <main className="container">
      <h1>{t('faq.title', 'Frequently asked questions')}</h1>
      {questions.map((item) => (
        <details key={item.q}>
          <summary>{item.q}</summary>
          <p>{item.a}</p>
        </details>
      ))}
    </main>
  );
}
app/routes.ts
// app/routes.ts
import { route } from '@tanstack/virtual-file-routes';

export const routes = [
  route('/faq', 'faq.tsx'), // a new page at /ar/faq and /en/faq
];
Linking to it
import { Link } from '@salla.sa/twilight-theme-engine/components/common';

// A path without the locale: Link adds /ar or /en for you.
<Link to="/faq">FAQ</Link>
In engine terms
  • twilightReact() merges DEFAULT_BASE_ROUTES with app/routes.ts into a Map keyed by path, so a custom path replaces a built-in one (generateRouteFiles in src/vite/adapters/tanstack.adapter.ts). Every route is registered as /{-$locale}<path>, an optional locale param.
  • The generated {-$locale} wrapper redirects an unknown locale to /ar/…, adds /ar on a multilingual store and removes the locale on a store with one language.
  • Route modules: { id, loader(ctx, extend?), head(ctx, data), Component } from @salla.sa/twilight-theme-engine/routes/<name>, never the /routes barrel. A route that needs the engine's data but its own look can reuse Product.loader and withHead(Product) with a component of its own: see Fork a route.
  • On localhost and the preview host the store's username comes before the locale; the router strips and re-adds it. Use Link and useNavigate(), never a path built from window.location.
  • The inspector above is a simplified matcher over a selection of the engine's routes. TanStack Router ranks static segments above params the same way.
  • useRouteId() is useSyncExternalStore over the request context. The router writes the id when the page hydrates and after every navigation resolves, putting the router's own route id through TANSTACK_ROUTE_ID_MAP (resolveRouteId, src/tanstack/router.tsx); an id the table does not list comes back unchanged. The <body> class is a second table, ROUTE_CLASS_MAP in src/tanstack/document-class.ts, and is written only while routeClass is on — which is also what writes the id during the server render.
  • The page record is the deepest matched loader's page, stored when the loaders finish and handed to the Salla SDK as Salla.config.set('page', …); each client navigation then fires route::changed with it. The store is written before useRouteId() changes, but reading it beside that hook is not enough to stay current: the hook renders again only when the id itself changes, and two products in a row share product.single. Inside the route subtree the loader's own data is the answer; outside it, route::changed is.
  • Reference: Route modules, withHead, Link, useRouteId, RouteId, the page-detection hooks, useLocation and the Page type. Recipe: Track page views.
Check yourself

Your theme lists route('/cart', 'my-cart.tsx') in app/routes.ts. What happens?