File-Based Routing in React: Next.js App Router vs TanStack Router
A practical breakdown of Next.js App Router and TanStack Router — how file-based routing works in each, where they differ, and which one fits your project.
Why File-Based Routing Took Over
Routing used to be this thing you configured in a giant routes.js file. You'd define every path by hand, import every page component, wire them together, and then spend twenty minutes debugging why /dashboard/settings wasn't rendering. Sound familiar?
File-based routing killed that pattern for most projects. The idea is simple: your folder structure *is* your route tree. Create app/about/page.tsx and /about just works. No import, no <Route> declaration, no ceremony. This isn't magic — frameworks scan your directory at build time (or dev startup) and generate the router for you.
Next.js pioneered this in the React ecosystem with its Pages Router back around 2017-2018. But the App Router, shipped in Next.js 13 and stabilized in 13.4 (2023), rewrote everything on top of React Server Components. TanStack Router came at file-based routing from a totally different angle — a client-side-first, type-safe alternative that doesn't care about SSR at its core.
Worth noting: these two routers have completely different mental models despite solving the same surface-level problem. Picking the wrong one for your architecture will cost you weeks. So let's actually compare them.
How Next.js App Router Structures Files
The App Router lives in an app/ directory. Every folder that represents a URL segment needs a page.tsx file inside it — that's what gets rendered at that route. But there's also layout.tsx, loading.tsx, error.tsx, not-found.tsx, and template.tsx, each with a specific role in the render tree.
app/
layout.tsx # root layout, always rendered
page.tsx # /
dashboard/
layout.tsx # wraps all /dashboard/* routes
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
blog/
[slug]/
page.tsx # /blog/:slug (dynamic segment)Dynamic segments use square brackets: [slug], [id], etc. Catch-all routes use [...slug]. Optional catch-all: [[...slug]]. Route groups — folders wrapped in parentheses like (marketing)/ — let you share layouts without adding a URL segment. That last one is genuinely useful when you want /pricing and /about to share a header but not be nested under any common path.
One more thing — parallel routes and intercepting routes exist in the App Router for advanced patterns like modals that maintain URL state. They use @ prefixes (@modal/) and (.) intercept syntax. Honestly, these are powerful but the notation takes a while to internalize. If you're building something like a photo gallery with modal detail views, they're worth the learning curve.
Server Components are the default in the App Router. Anything that needs client-side interactivity — event handlers, useState, useEffect — needs a 'use client' directive at the top of the file. This boundary is the most important mental shift when migrating from Pages Router or any other React routing setup.
TanStack Router's Take on File-Based Routing
TanStack Router v1 added file-based routing as an optional feature on top of its already solid code-based API. You opt into it by using a Vite plugin (@tanstack/router-plugin) or the CLI codegen tool. From there, you create a routes/ directory and the plugin watches it, generating a routeTree.gen.ts file automatically.
routes/
__root.tsx # root layout route
index.tsx # /
dashboard/
index.tsx # /dashboard
settings.tsx # /dashboard/settings
blog/
$postId.tsx # /blog/:postId (dynamic segment)The $ prefix for dynamic segments instead of [] is the main syntax difference you'll notice. Route files export a createFileRoute call that attaches loaders, search param schemas, and component definitions all in one place.
// routes/blog/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/blog/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
component: function PostPage() {
const { post } = Route.useLoaderData()
return <article>{post.title}</article>
},
})In practice, this feels much closer to how Remix works than how Next.js App Router works. Loaders are colocated with routes, data is typed end-to-end, and there's no jumping between page.tsx and loading.tsx and error.tsx — you handle all that in one file if you want. The type safety on search params alone is worth looking at: you define a Zod schema, and useSearch() returns fully typed params. No more router.query.page as string.
Type Safety: Where They Diverge Sharply
This is where TanStack Router genuinely pulls ahead for client-rendered or hybrid apps. Every route, every param, every search param is typed at the framework level. When you call <Link to="/blog/$postId" params={{ postId: post.id }} />, TypeScript knows /blog/$postId expects a postId param. Typo the route? Compile error. Pass the wrong param type? Compile error. It's the closest thing to end-to-end type safety you'll find in a client-side router.
Next.js App Router improved its TypeScript support significantly in version 14 — params and searchParams props in page.tsx are typed based on the folder structure. But it's not as strict or ergonomic as TanStack Router's approach, especially for search params, which Next.js treats as Record<string, string | string[] | undefined> by default.
Look, if you're building a data-heavy dashboard where URLs encode filter state, sort order, pagination, and tab selection — and you want all of that type-safe and validated — TanStack Router's search param schemas are a genuine quality-of-life upgrade. For a marketing site or a blog, this difference barely matters.
That said, Next.js wins on the server side. Server Components, generateStaticParams, generateMetadata, Incremental Static Regeneration — none of that exists in TanStack Router's world. If SEO, TTFB, or server-rendered HTML matters for your use case, Next.js is still the answer.
Layouts, Nesting, and the Render Tree
Both routers support nested layouts, but they wire them up differently. In Next.js, a layout.tsx file wraps every route in that directory and all its subdirectories. You can stack as many as you want. The tricky part is that layouts don't re-render on navigation between sibling routes — they persist. That's intentional and great for shared state like sidebars. But it can surprise you when you expect a fresh mount.
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-8">{children}</main>
</div>
)
}TanStack Router handles this through "layout routes" — route files that render an <Outlet /> component where child routes appear. The __root.tsx file is always the outermost layout. Nested layouts come from named layout files like _layout.tsx or from the directory structure itself. It's more explicit than Next.js — you see the nesting in the route definitions.
Quick aside: both routers support "pathless" layout segments. In Next.js that's route groups (groupName)/. In TanStack Router it's files prefixed with _ like _auth.tsx which wraps child routes without adding a URL segment. Same concept, different syntax.
For a project like a SaaS dashboard built with Empire UI's templates, you'll almost certainly want several layout levels — marketing shell, app shell, individual section shells. Both routers handle this well. The Next.js version is just more implicit about it.
When to Pick Next.js App Router
Next.js is the right call when your app needs server rendering, static generation, or edge runtime support. Content sites, e-commerce, marketing pages, blogs — anything where HTML needs to arrive ready from the server. The App Router's React Server Components model also means you can fetch data directly in components without client-side waterfalls, which makes a real difference for pages that depend on database queries or API calls behind auth.
The ecosystem is massive at this point. Deployment to Vercel is trivial. The official docs are thorough. Third-party libraries — auth providers, CMS adapters, analytics integrations — all document Next.js App Router support first. You're not going to hit a wall with missing ecosystem support.
Honestly, for anything that has a public-facing URL that needs to rank in search results, Next.js wins by default. You get server rendering, generateMetadata for per-page SEO tags, and sitemap generation built in. Pairing that with well-structured UI components from Empire UI means you can ship polished, indexed pages fast.
The tradeoff is complexity. The App Router has a lot of conventions, and some of them — parallel routes, intercepting routes, the difference between layout.tsx and template.tsx — take real time to internalize. The caching model in Next.js 14-15 also confused a lot of teams before the default behavior was changed in Next.js 15 to opt-in caching rather than opt-out.
When to Pick TanStack Router
TanStack Router is the better choice for client-rendered SPAs, Electron apps, internal tools, or any project where SSR isn't a requirement. If you're building a complex dashboard where type-safe search params matter more than TTFB — think admin panels, data exploration tools, analytics dashboards — it's noticeably more pleasant to work with.
The devtools are excellent. You get a visual route tree inspector, loader state, search param state, all in a panel you can toggle during development. For debugging why a route isn't loading data correctly, this is worth 20px of screen real estate during development.
// main.tsx — TanStack Router setup with file-based routes
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen' // auto-generated
const router = createRouter({ routeTree })
export default function App() {
return <RouterProvider router={router} />
}One more thing — TanStack Router works with any build tool: Vite, Rspack, even plain Webpack. You're not tied to a framework. If you're building a Vite SPA that pulls data from a separate API (say, a Supabase backend), TanStack Router gives you loader patterns that feel like Remix without the framework lock-in.
Worth noting: TanStack Router doesn't replace TanStack Query. They're complementary. Loaders handle initial route data, Query handles client-side caching and background refetching. Most real projects use both, and the integration is clean — loaders can await queryClient.ensureQueryData() and components can call useSuspenseQuery for the same cache entry.
FAQ
No — they're competing solutions for the same layer. Next.js App Router is built into the framework and can't be swapped out. TanStack Router works with Vite, Rspack, or any custom setup, but not inside a Next.js project.
Yes. The App Router is entirely file-system driven — there's no programmatic route registration API. If you need fully dynamic routes at runtime, you're looking at catch-all segments like [...slug] combined with your own routing logic inside the component.
It has SSR support, but it's not the primary use case and requires more manual setup compared to Next.js. For real SSR with streaming, React Server Components, and edge deployment, Next.js is the more mature option.
TanStack Router wins on type safety — route params, search params, and loader data are all fully typed at the framework level. Next.js improved in v14-15, but TanStack Router's type system is more thorough, especially for search params.