
Next.js is the default choice for React apps in 2026. We chose Vite + Tanstack Router + SPA instead. And our entire layout system is 7 lines of code.
staticData pattern lets each route declare its layout mode (app/public/fullscreen) in a single line, with the root layout resolving the deepest match replacing Next.js layout components with 7 lines of configuration and ~30 lines of resolution logic.import.meta.env.DEV a pattern that ships zero dev-only code to production while keeping the developer experience rich.In 2026, the React ecosystem has largely standardized on Next.js as the default framewok. Its App Router, Server Components, and SSR capabilities are powerful but they're designed for a specific use case: content-heavy, SEO-critical, publicly-accessible websites.
Labas is the opposite:
For this use case, SSR adds complexity without benefit. Server Components can't run client-side exam timers. Hydration mismatches are common with real-time state. And the App Router's layout system while powerful is overkill forn an app with three layout modes.
So we chose a SPA: React 19 + Vite + Tanstack Router. No SSR. No Server Components. No App Router.
The entire layout system lives in apps/web/src/lib/route-shell.ts:
That's it. Three layout modes, exported as constants. Each route file declares which shell it uses:
The root layout (__root.tsx) reads the staticData from the deepest matching route and conditionally renders the appropriate shell:
The resolveShell function walks matches from childmost to parentmost, finding the deepest staticData declaration. This means a nested route can override its parent's shell a /login route inside an app shell parent can declare routeShell.public and get the bare layout.
Shell | When to use | What renders |
routeShell.app | Authenticated app pages | Sidebar + <main id="main-content"> + SkipLink |
routeShell.public | Landing, login, auth flows, 404 | Bare min-h-screen wrapper, no sidebar |
routeShell.fullscreen | Test-taking, immersive UIs | h-screen overflow-y-auto, no sidebar |
The app shell includes the sidebar navigation, responsive margins (md:ml-64), and an accessibilty SkipLink that jumps to #main-concent. The public shell is a bare wrapper no sidebar, no navigation. The fullscreen shell is h-screen overflow-y-auto designed for the exam interface where every pixel matters.
Tanstack Router uses file-based routing like Next.js, but it's a SPA no server-side rendering, no App Router conventions, no page.tsx vs layout.tsx distinction.
The route structure looks like this:
The $.tsx file is the catch-all 404 route. It declares routeShell.public so no sidebar appears on 404 pages. We deliberately don't put a notFoundComponent in __root.tsx the 404 is handled exclusively by the splat route.
Heavy route components are split into two files:
The route file is thin:
The heavy component lives in components/routes/ and is wrapped in Suspense with a loading skeleton. This pattern keeps route files focused on routing logic while deferring component code splitting to the lazy import.
Tanstack Router DevTools and React Query DevTools are invaluable during development. But they shouldn't ship to production. We gate them with import.meta.env.DEV:
When NODE_ENV=production, import.meta.env.DEV is false the DevTools are never imported, never bundled, and never shipped. Vite's tree-shaking removes them entirely from the production build.
This is pattern that Next.js handles automatically (DevTools are dev-only by default). In a Vite SPA, we need to be explicit but the result is the same: zero dev-only code in production.
Choosing SPA over SSR isn't free. Here's what we sacrificed:
/) is a SPA route Google crawls it via Javascript rendering. For a product page, this is fine. For a content blog, it wouldn't be.loading.tsx, no error.tsx, no not-found.tsx automatic conventions. We handle loading states with Suspense, errors with route-level errorComponent, and 404s with $.tsx.For our use case, these tradeoffs are acceptable. The landing page is the only public route that benefits from SSR and it's a simple page that renders quickly even as a SPA.
staticData pattern (which powers our route shell system) is unique to Tanstack.useLocation hook that returns the current path. The sidebar component uses this to highlight the active link. No special configuration needed it's just React state derived from the router.$.tsx splat route catches all unmatched paths. It declares routeShell.public (no sidebar) and renderes a <NotFoundPage> component with a link back to the dasboard. We deliberately don't use a notFoundComponent in __root.tsx the splat route is the single source of truth for 404 handling.