Naalya Handbook
Design System

Layout & Pages

Page shells, the inline header pattern, and the navbar breadcrumbs that are driven entirely by the URL.

Open a few route files in the Education Hub and you'll notice something missing: there is no <PageContainer>, no <PageHeader>, no shared layout component wrapping every screen. That's deliberate. The structure of a page — its outer padding, its title block, its breadcrumbs — comes from three small, separate conventions rather than one big component. Once you can name all three, you can read (and write) any screen in the app.

  • Page shells are CSS utility classes, not components — you reach for .hub-page or .guest-page.
  • Headers are a few inline Tailwind classes you repeat, not a component you import.
  • Breadcrumbs don't live on the page at all — they live in the navbar, and they're built from the URL.

Let's take them one at a time.

Page shells

Every screen starts with a wrapper div carrying one shell class. The class does all the layout work — centering, max width, padding, and scrolling — so the route component can stay focused on content.

src/routes/.../students/index.tsx
function RouteComponent() {
  return (
    <div className="hub-page">
      {/* header block + content go here */}
    </div>
  )
}

The two shells differ only in who they're for. Both are defined as utility classes in src/styles.css:

src/styles.css
.hub-page {
  @apply bg-background relative mx-auto h-(--content-height)
    max-w-7xl overflow-y-auto p-4 md:p-6;
}

.guest-page {
  @apply bg-background relative mx-auto w-full overflow-y-auto
    max-w-7xl p-4 md:p-6 lg:px-10;
}

Use .hub-page for staff and platform screens — it's capped at max-w-7xl, centered with mx-auto, and crucially pinned to h-(--content-height) with overflow-y-auto. That fixed height is what makes the page itself scroll inside the app frame, leaving the navbar and sidebar fixed in place. .guest-page is the wider sibling for the public-facing guest portal — same max width, but with roomier horizontal padding (lg:px-10) and no clamped height.

Why a class and not a component

A shell that's just a class keeps route files genuinely thin — one div, one className, done. There's no prop drilling and nothing to import. The cost is that the convention lives in your head rather than in a type signature, which is exactly why it's written down here.

Keeping route files thin

The shell handles the outside; the -partials/ convention handles the inside. When a screen grows — big sections, the bodies of each tab — that markup moves out into sibling files inside a -partials/ folder. The leading - is load-bearing: TanStack Start's file-based router ignores any file or folder whose name starts with a hyphen, so partials never accidentally become routes.

src/routes/.../students/index.tsx
import StudentOverviewTab from './-partials/-student-overview.tab'
// -partials/ and the leading "-" keep this out of the router

So a route file ends up as a slim orchestrator — shell, header, and a handful of imported partials — rather than a 400-line wall.

Headers

There is no <PageHeader> to import. A list-page header is a small, memorable arrangement of Tailwind classes you write inline: a flex items-end justify-between row, with a title block on the left and a primary action on the right.

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>
</div>

The left side is always the same shape — a flex flex-col gap-1 column holding an <h2 className="text-2xl font-semibold"> and a muted <p> of supporting copy. The right side, when a page has a create action, is a primary <Button> — and it's wrapped in <Can> so it only renders for users who are allowed to perform it.

header with a gated action
<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>
  <Can action={Action.CREATE} resource={Resource.STUDENT}>
    <Button>Add student</Button>
  </Can>
</div>

items-end is doing real work

items-end aligns the button's baseline with the bottom of the title block, so a tall two-line title and a single-line button still sit on the same line. It's a small detail that keeps every header in the app looking consistent.

Detail pages flip the formula: instead of a title-plus-action row, they open with a back link and a hero card that introduces the single entity you're looking at. Same idea — inline composition, no shared header component — just a different arrangement for "one thing" versus "a list of things".

Here's the part that surprises people. The breadcrumb trail you see at the top of a staff screen is not rendered by the page. It lives in the navbar, and it's reconstructed from the URL on every navigation — the page never has to declare "you are here".

The navbar splits the current pathname into segments, skips the fixed prefix (staff-hub / campus / $campusId), and asks a hook to turn each remaining segment into a label:

src/components/navigation/staff.breadcrumb.tsx
const sections = pathname.split('/').filter(Boolean).slice(3)
const { resolveSegment } = useBreadcrumbLabels()

const breadcrumbs = sections.map((segment, idx) => {
  const resolved = resolveSegment(segment)
  // ...build an href from the segments up to here
})

The work of naming a segment happens in useBreadcrumbLabels. It runs in two passes. First it checks a static SEGMENT_LABELS map — a plain lookup that turns known URL words into proper titles:

src/lib/use-breadcrumb-labels.ts
const SEGMENT_LABELS: Record<string, string> = {
  'staff-hub': 'Staff Hub',
  campus: 'Campus',
  students: 'Students',
  // ...
}

If the segment isn't a known word, it's probably an id — a campus id, a student id. Static maps can't help there, so the hook runs dynamic resolvers: small useQuery calls, one per id param, that fetch the entity and hand back its display name.

src/lib/use-breadcrumb-labels.ts
const student = useQuery({
  ...StudentOptions.getById(params.studentId ?? ''),
  enabled: !!params.studentId,
})
// dynamicLabels[params.studentId] = `${firstName} ${lastName}`

So a URL like .../students/clx9... shows Students › Ada Lovelace, not a raw id. While the name is still loading, resolveSegment returns a placeholder — recognized by an isLikelyId heuristic — so the crumb never flashes a cryptic string.

The last crumb and raw ids are not links

Two rules decide whether a crumb is clickable. The last crumb is always rendered as a non-clickable BreadcrumbPage — you're already there. And any dynamic (id-resolved) crumb is also rendered as text, never a link. Only the static, intermediate segments become BreadcrumbLinks. That's the showAsText = isLast || crumb.isDynamic line doing its job.

The payoff: nobody maintains a breadcrumb config. Add a route, add its segment to SEGMENT_LABELS (and a resolver if it carries an id), and the trail builds itself from the path.

Where to go next

On this page