Route Overrides
Hide, reorder, group, deep-link, replace or rebuild routes from src/overrides/routes.tsx - the most used extension point.
The file to edit is src/overrides/routes.tsx. It is the single most used extension point in Thunder UI.
Keys are SDK module names, group names (as the SDK reports them, usually capitalised), or the built-in keys overview, wallet and notifications.
The One Rule That Governs Everything Here
| Form | Effect |
|---|---|
key: { … } | Replaces the generated route entirely. Only what you write exists. |
key: { merge: true, … } | Merges over the generated route. Keeps its path, Component and generated children. |
Everything below follows from that one distinction.
Hiding a Module
Two different outcomes, and the difference matters:
// Remove it completely - the list/form/detail pages stop existing.
// No `merge`, so `{ display: false }` *becomes* the whole route.
tenants: { display: false },
apiKeys: { display: false },
accessControl: { display: false },
// Keep the pages reachable by URL, just hide them from navigation.
// Use this for screens you link to yourself from somewhere else.
attributeValues: { merge: true, display: false },
stores: { merge: true, display: false },Gating Visibility on a Permission
display may be a function, re-evaluated whenever navigation renders. Any permission works - including a different module's, which is how you hide a screen behind the permission that actually guards its data:
products: { merge: true, display: () => ThunderSDK.isPermitted(ThunderSDK.products.update) },
brands: { merge: true, display: () => ThunderSDK.isPermitted(ThunderSDK.brands.update) },
// gated by a *different* module's permission
categories: {
merge: true,
display: () => ThunderSDK.isPermitted(ThunderSDK.manageOrders.get),
},Permissions come from thunder-core and are registered before the app renders - see Authentication.
Reordering Modules and Groups
priority sorts ascending; ties keep declaration order. Group keys work exactly like module keys:
orders: { merge: true, priority: 0 },
orderSubscriptions: { merge: true, priority: 1 },
// group-level overrides - keyed by the group name
Management: { merge: true, priority: 1 },
Admin: { merge: true, priority: 4 },
Orders: { merge: true, priority: 4 },A group's own display is ANDed with "has at least one visible child", so a group disappears automatically once everything inside it is hidden. You do not need to hide the group as well.
Adding Your Own Page Into a Group
Set group to the group you want it to appear under. The group does not have to come from the SDK - naming a new one creates it:
refundableOrders: {
name: "Refundable Orders",
path: "refundableOrders",
group: "Management", // joins an existing group
icon: IconCreditCardRefund,
display: () => ThunderSDK.isPermitted(ThunderSDK.manageOrders.refundable),
Component: RefundableOrders,
},
manageCareers: {
name: "Manage Careers",
path: "manageCareers",
group: "Careers", // creates a new group
icon: IconDeviceDesktopCog,
display: () => ThunderSDK.isPermitted(ThunderSDK.manageCareers.get),
Component: ManageCareers,
},Routes are grouped by group ?? "Other". A route with no group therefore lands in a group literally named Other - which is visible if that route is. Always set group on a custom route unless you actually want that. (This is also why a bare { display: false } is safe: with nothing visible inside it, the Other group hides itself.)
Hidden Deep-Linked Routes
A route with display: false is reachable by URL but absent from navigation - the usual shape for a detail page you link to from a list. path can be several segments with parameters:
manageCareerDetail: {
path: "manageCareers/:id",
group: "Careers",
display: false,
Component: ManageCareerDetail,
},
orderSubscriptionsTriggers: {
path: "orderSubscriptions/:subscriptionId/triggers/:triggerId",
group: "Orders",
display: false,
Component: SubscriptionTriggerDetailView,
},Rebuilding the Children Yourself
Because an override without merge replaces the route outright, you can declare the children by hand - and still reuse the generated pages. ListPage, ViewPage and FormPage are exported from core and take group and name, so you keep the schema-driven screens while changing the URL layout.
Here the module is relabelled and its form segment becomes apply:
import { Outlet } from "react-router"
import { ListPage } from "@/core/crud/ListPage.tsx"
import { ViewPage } from "@/core/crud/ViewPage.tsx"
import { FormPage } from "@/core/crud/FormPage.tsx"
careers: {
name: "Applications", // nav label differs from the module name
path: "careers",
group: "Careers",
icon: IconDeviceDesktopCog,
display: () => ThunderSDK.isPermitted(ThunderSDK.careers.get),
Component: () => <Outlet />, // parent renders its children
children: [
{
index: true,
display: false,
Component: () => <ListPage group="Careers" name="careers" />,
},
{
path: ":id",
display: false,
Component: () => <ViewPage group="Careers" name="careers" />,
},
{
path: "apply", // instead of the generated `form`
display: false,
Component: () => <FormPage group="Careers" name="careers" />,
},
],
},Mix this freely with your own components: replace only the form and keep the generated list, wrap ListPage in your own layout, or add extra children the generator never produces.
Replacing a Built-in Page
// keeps the built-in's icon, priority and permission checks
overview: { Component: MyDashboard },Built-in keys: overview, wallet, notifications. See Built-in Pages.