Add a Page
Create a new route the right way — guard it, wrap it in a page shell, add the header and URL-state tabs.
Adding a page in the Education Hub is mostly filling in a shape that already exists. The routing is file-based, the page shell is a CSS class, the header is a fixed little flexbox, and tabs keep their state in the URL. Once you've seen one real route, you've seen them all — so this recipe walks you through the same five moves every staff page makes, using the campus Students page as the worked example.
The conventions themselves are documented elsewhere — Routing & App Shell for how files become routes, and Layout & Pages for the visual shell. This page is the checklist that ties them together.
Copy a neighbour
You almost never start from a blank file. Find the closest existing route — a sibling page in the same folder — copy it, and change the pieces. Every step below is something you're adjusting, not inventing.
Step 1: Create the route file
Routes live under src/routes/_authenticated/..., and the file path is the URL. Drop a file in the right folder and TanStack Router generates the route for you — no manual registration. The Students page sits at a campus-scoped path, so its $campusId segment becomes a route param:
export const Route = createFileRoute(
'/_authenticated/staff-hub/campus/$campusId/students/',
)({
component: RouteComponent,
})If the page should be gated, add a beforeLoad guard. requirePermission runs before the route loads, redirects unauthenticated users to /, and sends anyone who fails the check to /403 — so the component never renders for someone who shouldn't see it:
export const Route = createFileRoute('/_authenticated/staff-hub/.../students/')({
beforeLoad: requirePermission({ action: 'read', resource: 'student' }),
component: RouteComponent,
})You can guard by userType, role, or an action + resource pair — see Permissions for the full menu and instance-level checks.
Step 2: Wrap the body
Every page body lives inside a page shell. These are CSS utility classes, not components — .hub-page for staff and platform pages, .guest-page for guest-facing ones. They handle the max-width, centering, padding, and scroll region so you don't have to:
function RouteComponent() {
const { campusId } = Route.useParams()
return (
<div className="hub-page">
{/* header, tabs, content */}
</div>
)
}Pick the right shell
.hub-page constrains height to the content area and scrolls inside the app shell — that's what you want for the staff sidebar layout. .guest-page is full-width with extra side padding for the guest portals. Don't reach for raw mx-auto max-w-7xl — use the class so spacing stays consistent everywhere.
Step 3: Add the header
Every page opens with the same inline header — a title and muted description on the left, an optional primary action on the right. The layout is a single flex items-end justify-between:
<div className="flex items-end justify-between">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-semibold">Students</h2>
<p>Manage students for this campus.</p>
</div>
{/* primary action goes here */}
</div>When the page has a primary action — Add student, Create class — put a <Button> on the right and wrap it in <Can> so it only shows for users who are allowed to do it:
<Can action={Action.CREATE} resource={Resource.STUDENT}>
<Button onClick={openAddStudent}>Add student</Button>
</Can>Step 4: Add URL-state tabs
If the page has tabs, store the active tab in the URL instead of local state. That way a refresh or a shared link lands on the same tab. The Hub uses nuqs and its useQueryState hook for this — the tab name becomes a ?currentTab= query param:
const [currentTab, setCurrentTab] = useQueryState(
'currentTab',
parseAsString.withDefault('students'),
)Wire that into the <Tabs> component, then keep each tab's body in a co-located partial — files named -partials/*.tab.tsx next to the route. The route file stays a thin wiring layer while the heavy content lives in its own file:
<Tabs value={currentTab} onValueChange={setCurrentTab}>
<TabsList>{/* triggers */}</TabsList>
<TabsContent value="students">
<CampusStudentDataTable campusId={campusId} />
</TabsContent>
</Tabs>The -partials/ folder convention is covered in Components & Patterns. The leading dash tells the router to ignore the file as a route — it's a private sibling, not a page of its own.
Step 5: Add breadcrumb labels
The app shell builds breadcrumbs by reading your URL segment by segment. A static segment like students needs a friendly label, and a dynamic one like $campusId needs to resolve an ID into a real name. Both live in use-breadcrumb-labels.ts.
For a static segment, add a line to SEGMENT_LABELS:
const SEGMENT_LABELS: Record<string, string> = {
'staff-hub': 'Staff Hub',
campus: 'Campus',
students: 'Students',
// ...add your new segment here
}For a dynamic param, add a query that resolves the ID and map it in dynamicLabels — that's how $campusId becomes the campus's actual name in the trail:
const campus = useQuery({
...CampusOptions.getById(params.campusId ?? ''),
enabled: !!params.campusId,
})
// dynamicLabels: { [params.campusId]: campus.data?.name }Skip this and you'll still ship
Forget a label and the breadcrumb falls back to a title-cased version of the segment — so service-health becomes Service Health on its own. It's only the dynamic IDs that truly need wiring; a raw ID in the trail is the tell that a resolver is missing. See Layout & Pages for the full breadcrumb story.