Add a custom route

Serve a new URL from your theme, with data, a title, a loading state and a locale-aware path.

Framework: TanStack Start

This page is written for TanStack Start, the default and currently supported framework. See what depends on the framework.

The engine already serves every built-in page. Add a custom route when you need a URL it does not have, such as /new-arrivals, /faq or /promotions/summer. To change a page the engine already has, use Fork a built-in route instead.

Adding a route takes two edits: one line in app/routes.ts and one file in app/routes/.

Declare the path

app/routes.ts is the list of pages your theme adds or replaces. The second argument is a file name inside app/routes/.

app/routes.ts
import { route } from '@tanstack/virtual-file-routes';

export const routes = [
  route('/new-arrivals', 'new-arrivals.tsx'),
];

Create the route file

Write the file the entry points to. The path in createFileRoute is the same URL with {-$locale} in front, so one route answers /new-arrivals, /ar/new-arrivals and /en/new-arrivals.

app/routes/new-arrivals.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';
import { product } from '@salla.sa/twilight-theme-engine/api/product';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import { ProductCard } from '@salla.sa/twilight-theme-engine/product';

export const Route = createFileRoute('/{-$locale}/new-arrivals')({
  loader: () => {
    const { queryClient } = getTwilightContext();
    return queryClient.fetchQuery(product.queries.list({ source: 'latest', perPage: 12 }));
  },
  head: () => ({ meta: [{ title: 'New arrivals' }] }),
  component: NewArrivals,
});

function NewArrivals() {
  const { t } = useTranslation();
  const { items } = Route.useLoaderData();

  return (
    <div className="container">
      <h1>{t('pages.new_arrivals.title', 'New arrivals')}</h1>
      <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
        {items.map((item) => (
          <ProductCard key={item.id} product={item} />
        ))}
      </div>
    </div>
  );
}

Restart and open it

The route tree (app/routeTree.gen.ts) is regenerated when the dev server starts. Restart it and open http://localhost:5173/en/new-arrivals. You should see the heading and a grid of 12 products. Add the pages.new_arrivals.title key to both files in locales/ (Add translation keys).

The parts of a route file

PartWhat it does
createFileRoute('/{-$locale}/…')The URL. Always start with {-$locale} so the language prefix works.
loaderFetches the data on the server for the first visit, and in the browser on later navigation.
headSets the page title and meta tags. See SEO and head.
pendingComponentA skeleton shown while the loader runs. Optional.
componentReads Route.useLoaderData() and renders.

URLs with a value in them

Put a $ before the part that changes, in both the manifest and the route path:

app/routes.ts
route('/promotions/$id', 'promotions.$id.tsx'),
app/routes/promotions.$id.tsx
import { createFileRoute } from '@tanstack/react-router';
import { product } from '@salla.sa/twilight-theme-engine/api/product';

export const Route = createFileRoute('/{-$locale}/promotions/$id')({
  loader: ({ params }) => product.findOrThrow(params.id),
  head: ({ loaderData }) => ({ meta: [{ title: loaderData?.name }] }),
  component: Promotion,
});

function Promotion() {
  const item = Route.useLoaderData();
  const { id } = Route.useParams();
  return <h1>{item.name} (#{id})</h1>;
}

params also holds locale, which is undefined when the URL has no language prefix. A value can be mixed with fixed text, like the engine's own /$slug/p{$id} for products.

Query strings

To read ?page=2, describe the search params you accept with validateSearch:

app/routes/new-arrivals.tsx
export const Route = createFileRoute('/{-$locale}/new-arrivals')({
  validateSearch: (search: Record<string, unknown>) => ({
    page: Number(search.page) || 1,
  }),
  loaderDeps: ({ search }) => ({ page: search.page }),
  loader: ({ deps }) => {
    const { queryClient } = getTwilightContext();
    return queryClient.fetchQuery(
      product.queries.list({ source: 'latest', perPage: 12, page: deps.page })
    );
  },
  // head and component as above (with the same imports)
});

loaderDeps is what makes the loader run again when ?page= changes.

Loading and missing data

  • While loading: add pendingComponent: () => <Skeleton />. The engine ships skeletons for its own pages, see the skeleton components.
  • Not found: the ...OrThrow functions on the API modules (product.findOrThrow) turn a 404 or 410 answer into the engine's not-found page. Any other error reaches the router's error page. See Errors, timeouts and retries.
  • Signed-out customers: a page that only works for signed-in customers should check useUser().isLoggedIn in its component and show a sign-in prompt instead of the content.

Use the engine's Link so the language prefix is kept:

import { Link } from '@salla.sa/twilight-theme-engine/common';

<Link to="/new-arrivals">New arrivals</Link>

Add it to the menu

A route is only a URL. A merchant adds it to a menu in the dashboard, or you link to it from a component in the header or footer (Layout and components).

Common mistakes

SymptomCause
404 right after adding the routeThe route tree is stale. Restart the dev server.
Works at /faq but not /ar/faqThe createFileRoute path is missing {-$locale}.
The file disappears or changes on the next buildIt starts with // @auto-generated. Never put that marker in a file you wrote.
The path exists in the engine and your page is not shownYou replaced a built-in route by accident. See Fork a built-in route.
Window or localStorage errorsThe loader also runs on the server. Keep browser-only code in the component.

Next

On this page