Why I reach for TanStack Router
Typed routes turn a whole category of navigation bug into a build error. Here is the one that shipped to production in my own app before I moved, and whatβ¦
The slug on this post says TanStack Router is the best. I would not write that today β "best" is a claim about everyone's situation, and I only know mine. What I will defend is narrower and more useful: it converts a category of navigation bug from something you find in production into something that fails your build.
I know the size of that category precisely, because my own portfolio site shipped one of them and served it to real people for months.
The bug that made the case
The old version of this site had nine service pages at /services/<id>. Every one of them rendered the services list instead. The detail component β three hundred and thirty-four lines of it β never mounted at all.
The cause is two lines long. A layout route and a detail route were nested, and the layout rendered the list page directly rather than an outlet:
// services.tsx β the layout route
export const Route = createFileRoute('/services')({
component: ServicesList, // renders the LIST
});
// services.$serviceId.tsx nests underneath it, and never gets rendered
What made it survive so long is the detail that still bothers me: the prerendered HTML was correct. Each URL had the right title and the right meta description, because those were generated from data at build time. Crawlers saw nine distinct, correct pages. Every human saw the same list nine times.
So the monitoring was clean, search console was clean, and the only signal was a person opening the page β which, on a portfolio's service pages, is rare enough to take months.
What typed routes actually buy
The headline feature is that to is not a string, it is a union of the routes that exist. That sounds like a small ergonomic nicety. It is not. It moves link correctness from runtime to compile time.
Today, building the blog for this site, I wrote a post card before the post route existed. The compiler said:
Type '"/blog/$slug"' is not assignable to type
'"/" | "/about" | "/bio" | "/pricing" | ... 31 more ... | "/services"'
That is not an inconvenience, that is the gate doing its job. A dead internal link is one of those defects that no test catches β a link checker crawls what is rendered, and a link nobody rendered because the data was empty is invisible to it.
Route params get the same treatment. params={{ slug }} is checked against what the route actually declares, so renaming $slug to $postSlug breaks every call site immediately instead of producing a run of URLs with the literal string undefined in them.
Search params are the part people underuse
The feature I would miss most is not typed paths, it is typed search params with validation at the route.
My rule, in every project, is that anything a person would expect to survive a refresh lives in the URL: an open modal, the active tab, a wizard step, filters, sort, pagination, the search box. The test is whether they could paste the address to a colleague and have them see the same screen. For a filtered archive the answer has to be yes.
validateSearch: (search: Record<string, unknown>): BlogSearch => {
const page = Number(search.page);
return {
q: typeof search.q === 'string' && search.q !== '' ? search.q : undefined,
topic: typeof search.topic === 'string' && search.topic !== '' ? search.topic : undefined,
page: Number.isFinite(page) && page > 0 ? Math.trunc(page) : undefined,
};
},
Two things are happening here and both matter. The obvious one is that every component downstream gets typed values instead of parsing URLSearchParams itself. The one people skip is that this is a trust boundary: these values arrive from an address a stranger can edit. A negative page reaching a database range produces an inverted query; a crafted sort key reaching an order() call is a column name you did not choose. Validating at the route means there is exactly one place to get that right.
What I do not love
The generated route tree is a real file that has to stay in sync, so route generation runs before typecheck and before build, always. Forget that and you get errors about routes you have already written, which is a confusing first ten minutes for anyone new to a codebase.
The types are also genuinely heavy. On a large route tree, editor responsiveness is noticeably worse than with a router that types to as string. I think the trade is obviously right, but it is a trade, and pretending otherwise would be selling something.
And file-based routing has the usual cost: the filename is the URL, so a rename is a route change. That is fine once you know it and surprising exactly once.
Loaders, and the waterfall you did not know you had
The feature I underused for the longest is the route loader. Without one, a page mounts, renders a skeleton, fires its query from an effect, and only then discovers it needs a second thing β so a detail page that needs a record and its siblings does two round trips in sequence, and the second one does not start until the first has finished rendering.
A loader moves that decision to the route. The router knows what the page needs before the component exists, so the requests start while the previous page is still on screen and both are in flight together. On a fast connection this is invisible. On a phone on mobile data it is the difference between a page that appears and a page that assembles itself in front of you.
The related habit is preloading on intent. Hovering a link β or, on touch, beginning the press β is enough signal to start fetching. By the time the navigation commits, the data is frequently already there, and the perceived cost of moving around the app drops to nothing without a single change to the pages themselves.
What I would warn about: it is easy to move too much into a loader and end up blocking navigation on data the page could have rendered without. The rule I use is that the loader fetches what the page cannot render anything useful without, and everything else stays a query inside the component with its own loading state. A route that will not commit until three requests finish is a route that feels broken on a bad connection, and the fix is to be honest about which one is actually required.
Where it sits against the alternatives
React Router is the default, it works, and its data APIs have closed much of the gap. If I inherit a codebase on it I am not rewriting anything. Next.js answers a different question β it brings a server, and every project I run is built specifically to avoid needing one.
What I keep coming back for is that TanStack Router treats the URL as typed application state rather than as a string that happens to be in the address bar. Once you have built a few screens where filters, pagination and an open dialog all survive a refresh for free, going back feels like losing a limb.
The honest summary: it is not that this router is the best. It is that it makes an entire class of bug impossible to write, and I have personally shipped that bug.
https://aoneahsan.com/blog/why-tanstack-router-is-the-best