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/.
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.
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
| Part | What it does |
|---|---|
createFileRoute('/{-$locale}/…') | The URL. Always start with {-$locale} so the language prefix works. |
loader | Fetches the data on the server for the first visit, and in the browser on later navigation. |
head | Sets the page title and meta tags. See SEO and head. |
pendingComponent | A skeleton shown while the loader runs. Optional. |
component | Reads 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:
route('/promotions/$id', '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:
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
...OrThrowfunctions 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().isLoggedInin its component and show a sign-in prompt instead of the content.
Link to your page
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
| Symptom | Cause |
|---|---|
| 404 right after adding the route | The route tree is stale. Restart the dev server. |
Works at /faq but not /ar/faq | The createFileRoute path is missing {-$locale}. |
| The file disappears or changes on the next build | It starts with // @auto-generated. Never put that marker in a file you wrote. |
| The path exists in the engine and your page is not shown | You replaced a built-in route by accident. See Fork a built-in route. |
Window or localStorage errors | The loader also runs on the server. Keep browser-only code in the component. |
Next
- Overview: every built-in route, to copy patterns from
- SEO redirects: send an old URL to a new one
- Routes and pages: the concepts behind
app/routes.ts - Add a page: the short recipe