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-pageor.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.
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:
.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.
import StudentOverviewTab from './-partials/-student-overview.tab'
// -partials/ and the leading "-" keep this out of the routerSo 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.
<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.
<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".
Breadcrumbs
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:
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:
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.
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.