Naalya Handbook
Recipes

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:

src/routes/_authenticated/staff-hub/campus.$campusId/students/index.tsx
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:

route options
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:

src/routes/.../students/index.tsx
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:

src/routes/.../students/index.tsx
<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:

header action
<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:

src/routes/.../students/index.tsx
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:

src/routes/.../students/index.tsx
<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:

src/lib/use-breadcrumb-labels.ts
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:

src/lib/use-breadcrumb-labels.ts
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.

Where to go next

On this page