
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.
Repo: https://github.com/rogasper/labas-bahasa
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:
export type RouteShell = "public" | "fullscreen" | "app";
export const routeShell = {
public: { shell: "public" as const },
fullscreen: { shell: "fullscreen" as const },
app: { shell: "app" as const },
};That's it. Three layout modes, exported as constants. Each route file declares which shell it uses:
import { routeShell } from "@/lib/route-shell";
export const Route = createFileRoute("/dashboard")({
staticData: routeShell.app, // ← one line
component: DashboardPage,
});The root layout (__root.tsx) reads the staticData from the deepest matching route and conditionally renders the appropriate shell:
function resolveShell(matches: AnyRouteMatch[]): RouteShell {
for (let i = matches.length - 1; i >= 0; i--) {
const shell = matches[i]?.staticData?.shell;
if (shell) return shell as RouteShell;
}
return "app"; // default
}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 |
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:
apps/web/src/routes/
__root.tsx # Root layout (reads staticData, renders shell)
index.tsx # Landing page → redirects to /dashboard if logged in
index.lazy.tsx # Lazy-loaded landing page component
dashboard.tsx # Authenticated home page
login.tsx # Login page (routeShell.public)
generate.tsx # AI Generator wizard
bank.tsx # Question Bank browse
bank.$id.tsx # Package detail
builder.tsx # Test Builder
builder.combo.tsx # Package Combiner
exam.$id.tsx # Exam Interface (routeShell.fullscreen)
exam.$id.attempt.$attemptId.tsx # Exam attempt (routeShell.fullscreen)
analytics.tsx # Deep Analytics dashboard
settings.tsx # Settings (API keys, profile)
$.tsx # Catch-all 404 (routeShell.public)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:
generate.tsx # Thin route file (staticData, loader, component reference)
components/routes/GeneratePage.tsx # Heavy component (wrapped in Suspense)The route file is thin:
export const Route = createFileRoute("/generate")({
staticData: routeShell.app,
component: GeneratePage,
});
const GeneratePage = lazy(() => import("../components/routes/GeneratePage"));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:
{import.meta.env.DEV && (
<>
<TanStackRouterDevTools />
<ReactQueryDevtools />
</>
)}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 errorComponentFor 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.routeShell.fullscreen | Test-taking, immersive UIs | h-screen overflow-y-auto, no sidebar |
$.tsxuseLocation 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.