Routing & App Shell
File-based TanStack Router: the root shell, the _authenticated gate, dynamic segments, and the -partials convention.
There is no file in this app that lists the routes. Instead, the folder structure under src/routes is the router.
TanStack Router reads that folder layout and generates routeTree.gen.ts from it. So the URL a page lives at is decided entirely by where its file sits and what it is called. Rename a file and you have changed a URL.
This means you can find any page from its URL, and work out any URL from a filename — once you know what the special characters mean.
The naming rules
Four characters do all the work. This table is the whole convention; the rest of the page explains each one.
| In a filename | Means | Example → URL |
|---|---|---|
_name | A layout that adds nothing to the URL. It wraps its children but disappears from the address. | _authenticated/student → /student |
$name | A parameter — this part of the URL varies. | campus.$campusId → /campus/:campusId |
-name | Not a route at all. Skipped entirely. Used for helper files kept next to the page that uses them. | -class-overview.tab.tsx → no URL |
. | Just a shorthand for a folder separator. | campus.$campusId is the same as campus/$campusId/ |
One more, less common: a trailing underscore on a parameter ($classId_) means "do not nest inside the parent layout" — the route stands on its own.
Two filenames also carry meaning:
| Filename | What it is |
|---|---|
route.tsx | A layout. Wraps the pages in its folder with shared furniture — a sidebar, a navbar — and renders an <Outlet /> where they appear. |
index.tsx, $id.tsx | Pages. Actual screens that render content. |
The app shell
__root.tsx is the outermost route — the app shell every page renders inside. It does two jobs: declare the document <head> (title, viewport, the stylesheet) and mount the HTML skeleton with global providers.
function RootDocument({ children }: { children: React.ReactNode }) {
useAscii()
return (
<html lang="en">
<head>
<HeadContent />
{/* pre-paint theme script — see below */}
</head>
<body>
<AppProvider> {children}</AppProvider>
<Scripts />
</body>
</html>
)
}<AppProvider> mounts the TanStack Query client, the theme provider, and the devtools. Because it sits at the very top, every page below it can reach the query cache and the current theme without setting anything up.
The root also owns the outermost error boundary, RootErrorBoundary. When it catches a network, timeout, or server error, it shows a friendly <ServerDown /> screen rather than a stack trace.
Theme before paint
Here's the clever bit. If React waited until hydration to decide light vs. dark, you'd see a white flash before the dark theme kicked in. To avoid that, the root injects a tiny inline script that runs before React renders anything:
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var s=localStorage.getItem('bellefull-theme');var t=s?JSON.parse(s).state.theme:'light';document.documentElement.classList.add(t==='dark'?'dark':'light')}catch(e){document.documentElement.classList.add('light')}})()`,
}}
/>It reads the persisted theme straight out of localStorage (the zustand-persist payload under the key bellefull-theme) and adds the right class to <html> immediately.
No flash, by design
The pre-paint script handles the user's chosen theme. A second layer — the signed-in school's branding — is warmed in the _authenticated gate so the hub also paints with the right brand colors on first render. Two different flashes, two different fixes.
The _authenticated gate
_authenticated.tsx is the most important layout in the app: every signed-in page sits behind it.
Because of the leading underscore it adds nothing to the URL. So the file _authenticated/student/index.tsx serves /student — not /_authenticated/student.
Its one job is to check somebody is actually signed in before a protected page loads. That check goes in beforeLoad, which runs before the route renders anything:
export const Route = createFileRoute('/_authenticated')({
ssr: false,
beforeLoad: async () => {
const me = await queryClient.fetchQuery(AuthOptions.getMe())
if (!me) {
throw redirect({ to: '/', replace: true })
}
// ... warm the school branding (non-critical)
},
// ...
})Two details are worth pausing on.
ssr: false means every signed-in page renders in the browser only. The server never tries to work out who you are, which keeps user data off the server-rendered HTML entirely.
getMe() is the single source of truth for who is signed in. If it returns nothing, you are redirected to /.
Guards throw redirects, they don't return them
Notice the throw redirect({ to: '/' }). In beforeLoad you throw the redirect — you never return it. The gate also re-throws genuine NETWORK_ERROR / TIMEOUT / SERVER_ERROR codes (so they reach the root's <ServerDown />) instead of mistaking a backend outage for "logged out."
The component half is deliberately thin. It renders the school theme, the three global surfaces (the app-wide alert, confirm, and profile dialogs), and an <Outlet /> where the actual page goes:
component: () => (
<React.Fragment>
<AuthedSchoolTheme />
<AlertSurface />
<AlertConfirmSurface />
<UserProfileSurface />
<Outlet />
</React.Fragment>
),Those three surfaces are mounted once, high in the tree. That is why any signed-in page can open an alert or confirm dialog without mounting one itself — the dialog already exists, and the page just asks for it.
How tokens are stored and refreshed behind getMe() is its own topic: Sessions & Tokens.
Layouts vs. pages
Inside the gate, each area has its own route.tsx layout — and each adds its own guard on top of the sign-in check.
The student area's layout checks you are actually a student:
beforeLoad: async ({ location }) => {
await requirePermission({ userType: 'student' })()
const me = await queryClient.ensureQueryData(AuthOptions.getMe())
// ... redirect to /student/no-campus if the student has no campus
},So guards stack, and each one answers a different question:
_authenticatedasks are you signed in at all?- The area's
route.tsxasks are you the right kind of user for this section?
A student can never wander into the staff hub, because the staff layout's own guard stops them. How requirePermission works is covered in Permissions.
Dynamic segments
A $ marks a part of the URL that varies. The file campus.$campusId serves /staff-hub/campus/:campusId, and inside that route you read the actual value with Route.useParams().
Parameters chain when you drill down. This one file handles three of them:
classrooms/$classId_.streams.$streamId_.exams.$examId.tsx
→ /staff-hub/campus/:campusId/classrooms/:classId/streams/:streamId/exams/:examIdNotice the trailing underscores — $classId_ rather than $classId. That tells the router this route does not render inside its parent's layout. The exam page takes over the whole screen instead of appearing nested inside the class page.
The -partials convention
Anything whose name starts with a dash is skipped by the router completely. It never becomes a URL.
This solves a specific problem: you want a helper file to live next to the page that uses it, but everything in src/routes normally becomes a page. The dash opts out.
The most common use is splitting a big page into its tab bodies:
classes/
route.tsx → the layout
index.tsx → the list page
$classId.tsx → the detail page
-partials/
-class-overview.tab.tsx (not a route — a tab body)
-class-settings.tab.tsx
-class-streams.tab.tsxThe dash is your "not a route" marker
If you put a .tsx file in src/routes and it accidentally becomes a page you didn't want, the fix is almost always a leading dash. -form-utils.ts, -section-a-learner-info.tsx, -teacher-class.view.tsx — all colocated helpers, none of them URLs. When you split a page, prefix every helper with -.
The route groups
Zoom out and the top of the tree sorts into a few clear areas:
/$reference— the per-school public landing and role sign-in. Thereferenceparam is the school's slug; this group resolves the public school and shows its branded sign-in./auth/callback— the OAuth token-receipt route the provider redirects back to after sign-in./_authenticated/*— everything behind the gate, split by audience:guest,student,guardian,staff-hub,platform, andpay.
Each authenticated area has its own route.tsx layout and its own per-area guard, so a student can never wander into staff-hub and a guardian sees only the guardian shell. Which user types exist, and how they map to these areas, is laid out in User Types.
Where to go next
Authentication
What getMe() returns and how the session is kept alive behind the gate.
Sessions & Tokens
Where tokens live and the pre-paint of auth state.
Permissions
How requirePermission and the stacked route guards decide access.
User Types
The audiences behind guest, student, guardian, staff-hub, and platform.