Naalya Handbook

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 filenameMeansExample → URL
_nameA layout that adds nothing to the URL. It wraps its children but disappears from the address._authenticated/student/student
$nameA parameter — this part of the URL varies.campus.$campusId/campus/:campusId
-nameNot 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:

FilenameWhat it is
route.tsxA layout. Wraps the pages in its folder with shared furniture — a sidebar, a navbar — and renders an <Outlet /> where they appear.
index.tsx, $id.tsxPages. 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.

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

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

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

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

src/routes/_authenticated/student/route.tsx
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:

  1. _authenticated asks are you signed in at all?
  2. The area's route.tsx asks 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:

one file, three params
classrooms/$classId_.streams.$streamId_.exams.$examId.tsx
→ /staff-hub/campus/:campusId/classrooms/:classId/streams/:streamId/exams/:examId

Notice 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:

src/routes/.../classes/
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.tsx

The 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. The reference param 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, and pay.

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

On this page