Introducing TanStack Router
Introducing TanStack Router êŽë š
TanStack Router is an incredibly exciting project. Itâs essentially a fully-featuredclient-sideJavaScript application framework. It provides a mature routing and navigation system with nested layouts and efficient data loading capabilities at every point in the route tree. Best of all, it does all of this in atype-safemanner.
Whatâs especially exciting is that, as of this writing, thereâs a TanStack Start in the works, which will add server-side capabilities to Router, enabling you to build full-stack web applications. Start promises to do this with a server layer applied directly on top of the same TanStack Router weâll be covering here. That makes this a perfect time to get to know Router if you havenât already.
TanStack Router is more than just a router â itâs a full-fledged client-side application framework. So to prevent this post from getting too long, we wonât even try to cover it all. Weâll limit ourselves to routing and navigation, which is a larger topic than you might think, especially considering the type-safe nature of Router.
Article Series
Getting started
There are official TanStack Router docs and a quickstart guide, which has a nice tool for scaffolding a fresh Router project. You can also clone the repo used for this post (arackaf/tanstack-router-routing-demo
)and follow along.
The Plan
In order to see what Router can do and how it works, weâll pretend to build a task management system, like Jira. Like the real Jira, we wonât make any effort at making things look nice or be pleasant to use. Our goal is to see what Router can do, not build a useful web application.
Weâll cover: routing, layouts, paths, search parameters, and of course static typing all along the way.
Letâs start at the very top.
The Root Route
This is our root layout, which Router calls__root.tsx
. If youâre following along on your own project, this will go directly under theroutes
folder.
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => {
return (
<>
<div>
<Link to="/">
Home
</Link>
<Link to="/tasks">
Tasks
</Link>
<Link to="/epics">
Epics
</Link>
</div>
<hr />
<div>
<Outlet />
</div>
</>
);
},
});
ThecreateRootRoute
function does what it says. The<Link />
component is also fairly self-explanatory (it makes links). Router is kind enough to add anactive
class to Links which are currently active, which makes it easy to style them accordingly (as well as adds an appropriate aria-current="page"
attribute/value). Lastly, the<Outlet />
component is interesting: this is how we tell Router where to render the âcontentâ for this layout.
Running the App
We run our app withnpm run dev.
Check your terminal for the port onlocalhost
where itâs running.
More importantly, the dev
watch process monitors the routes weâll be adding, and maintains arouteTree.gen.ts
file. This syncs metadata about our routes in order to help build static types, which will help us work with our routes safely. Speaking of, if youâre building this from scratch from our demo repo (arackaf/tanstack-router-routing-demo
), you might have noticed some TypeScript errors on our Link tags, since those URLs donât yet exist. Thatâs right: TanStack Router deeply integrates TypeScript into the route level, and will even validate that your Link tags are pointing somewhere valid.
To be clear, this is not because of any editor plugins. The TypeScript integration itself is producing errors, as it would in your CI/CD system.
src/routes/\\\_\\\_root.tsx:8:17 - error TS2322: Type '"/"' is not assignable to type '"." | ".." | undefined'.
<Link to="/" className="[&.active]:font-bold">
Building the App
Letâs get started by adding our root page. In Router, we use the fileindex.tsx
to represent the root/
path, wherever we are in the route tree (which weâll explain shortly). Weâll create index.tsx
, and, assuming you have the dev
task running, it should scaffold some code for you that looks like this:
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: () => <div>Hello /!</div>,
});
Thereâs a bit more boilerplate than you might be used to with metaframeworks like Next or SvelteKit. In those frameworks, you just export default
a React component, or plop down a normal Svelte component and everythingjust works. In TanStack Router we have have to call a function calledcreateFileRoute
, and pass in the route to where we are.
The route is necessary for the type safety Router has, but donât worry, you donât have to manage this yourself. The dev process not only scaffolds code like this for new files, it also keeps those path values in sync for you. Try it â change that path to something else, and save the file; it should change it right back, for you. Or create a folder calledjunk
and drag it there: it should change the path to"/junk/"
.
Letâs add the following content (after moving it back out of the junk folder).
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: Index,
});
function Index() {
return (
<div>
<h3>Top level index page</h3>
</div>
);
}
Simple and humble â just a component telling us weâre in the top level index page.
Routes
Letâs start to create some actual routes. Our root layout indicated we want to have paths for dealing with tasks and epics. Router (by default) uses file-based routing, but provides you two ways to do so, which can be mixed and matched (weâll look at both). You can stack your files into folders which match the path youâre browsing. Or you can use âflat routesâ and indicate these route hierarchies in individual filenames, separating the paths with dots. If youâre thinking only the former is useful, stay tuned.
Just for fun, letâs start with the flat routes. Letâs create atasks.index.tsx
file. This is the same as creating an index.tsx
inside of an hypotheticaltasks
folder. For content weâll add some basic markup (weâre trying to see how Router works, not build an actual todo app).
import { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/tasks/")({
component: Index,
});
function Index() {
const tasks = [
{ id: "1", title: "Task 1" },
{ id: "2", title: "Task 2" },
{ id: "3", title: "Task 3" },
];
return (
<div>
<h3>Tasks page!</h3>
<div>
{tasks.map((t, idx) => (
<div key={idx}>
<div>{t.title}</div>
<Link to="/tasks/$taskId" params={{ taskId: t.id }}>
View
</Link>
<Link to="/tasks/$taskId/edit" params={{ taskId: t.id }}>
Edit
</Link>
</div>
))}
</div>
</div>
);
}
Before we continue, letâs add a layout file for all of our tasks routes, housing some common content that will be present on all pages routed to under/tasks
. If we had atasks
folder, weâd just throw aroute.tsx
file in there. Instead, weâll add atasks.route.tsx
file. Since weâre using flat files, here, we can also just name ittasks.tsx
. But I like keeping things consistent with directory-based files (which weâll see in a bit), so I prefertasks.route.tsx
.
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/tasks")({
component: () => (
<div>
Tasks layout <Outlet />
</div>
),
});
As always, donât forget the<Outlet />
or else the actual content of that path will not render.
To repeat,xyz.route.tsx
is a component that renders for the entire route, all the way down. Itâs essentially a layout, but Router calls them routes. Andxyz.index.tsx
is the file for the individual path atxyz
.
This renders. Thereâs not much to look at, but take a quick look before we make one interesting change.

Notice the navigation links from the root layout at the very top. Below that, we seeTasks layout
, from the tasks route file (essentially a layout). Below that, we have the content for our tasks page.
Path Parameters
The<Link>
tags in the tasks index file give away where weâre headed, but letâs build paths to view, and edit tasks. Weâll create/tasks/123
and/tasks/123/edit
paths, where of course 123
represents whatever the taskId
is.
TanStack Router represents variables inside of a path as path parameters, and theyâre represented as path segments that start with a dollar sign. So with that weâll addtasks.$taskId.index.tsx
andtasks.$taskId.edit.tsx
. The former will route to/tasks/123
and the latter will route to/tasks/123/edit
. Letâs take a look attasks.$taskId.index.tsx
and find out how we actually get the path parameter thatâs passed in.
simport { createFileRoute, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/tasks/$taskId/")({
component: () => {
const { taskId } = Route.useParams();
return (
<div>
<div>
<Link to="/tasks">Back</Link>
</div>
<div>View task {taskId}</div>
</div>
);
},
});
TheRoute.useParams()
object that exists on our Route object returns our parameters. But this isnât interesting on its own; every routing framework has something like this. Whatâs particularly compelling is that this one is statically typed. Router is smart enough to know which parameters exist for that route (including parameters from higher up in the route, which weâll see in a moment). That means that not only do we get auto completeâŠ

âŠbut if you put an invalid path param in there, youâll get a TypeScript error.

We also saw this with the Link tags we used to navigate to these routes.
<Link to="/tasks/$taskId" params={{ taskId: t.id }}>
if weâd left off the params here (or specified anything other thantaskId
), we would have gotten an error.
Advanced Routing
Letâs start to lean on Routerâs advanced routing rules (a little) and see some of the nice features it supports. Iâll stress, these are advanced features you wonât commonly use, but itâs nice to know theyâre there.
The edit task route is essentially identical, except the path is different, and I put the text to say âEditâ instead of âView.â But letâs use this route to explore a TanStack Router feature we havenât seen.
Conceptually we have two hierarchies: we have the URL path, and we have the component tree. So far, these things have lined up 1:1. The URL path:
/tasks/123/edit
Rendered:
root route -> tasks route layout -> edit task path
The URL hierarchy and the component hierarchy lined up perfectly. But they donât have to.
Just for fun, to see how, letâs see how we can remove the main tasks layout file from the edit task route. So we want the/tasks/123/edit
URL to render the same thing, but without the tasks.route.tsx
route file being rendered. To do this, we just rename tasks.$taskId.edit.tsx
to tasks_.$taskId.edit.tsx
.
Note thattasks
becametasks_
. We do needtasks
in there, where it is, so Router will know how to eventually find theedit.tsx
file weâre rendering,based on the URL. But by naming ittasks_
, we remove thatcomponentfrom the rendered component tree, even thoughtasks
is still in the URL. Now when we render the edit task route, we get this:

Notice howTasks layout
is gone.
What if you wanted to do the opposite? What if you have acomponenthierarchy you want, that is, youwantsome layout to render in the edit task page, but youdonâtwant that layout to affect the URL. Well, just put the underscore on the opposite side. So we have tasks_.$taskId.edit.tsx
which renders the task edit page, but without putting the tasks layout route into thecomponent hierarchy. Letâs say we have a special layout wewantto use only for task editing. Letâs create a_taskEdit.tsx
.
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_taskEdit")({
component: () => (
<div>
Special Task Edit Layout <Outlet />
</div>
),
});
Then we change our task edit file to this _taskEdit.tasks_.$taskId.edit.tsx
. And now when we browse to/tasks/1/edit
we see the task edit page with our custom layout (which did not affect our URL).

Again, this is an advanced feature. Most of the time youâll use simple, boring, predictable routing rules. But itâs nice to know these advanced features exist.
Directory-Based Routing
Instead of putting file hierarchies into file names with dots, you can also put them in directories. Iusuallyprefer directories, but you can mix and match, and sometimes a judicious use of flat file names for things like pairs of$pathParam.index.tsx
and$pathParam.edit.tsx
feel natural inside of a directory. All the normal rules apply, so choose what feels bestto you.
We wonât walk through everything for directories again. Weâll just take a peak at the finished product (which is also on GitHub). We have anepics
path, which lists out, well, epics. For each, we can edit or view the epic. When viewing, we also show a (static) list of milestones in the epic, which we can also view or edit. Like before, for fun, when we edit a milestone, weâll remove the milestones route layout.

So rather thanepics.index.tsx
andepics.route.tsx
we have epics/
index.tsx
and epics/
route.tsx
. And so on. Again, theyâre the same rules: replace the dots in the files names with slashes (and directories).
Before moving on, letâs briefly pause and look at the$milestoneId.index.tsx
route. Thereâs a$milestoneId
in the path, so we can find that path param. But look up, higher in the route tree. Thereâs also an$epicId
param two layers higher. It should come as no surprise that Router is smart enough to realize this, and set the typings up such that both are present.

Type-Safe Querystrings
The cherry on the top of this post will be, in my opinion, one of the most obnoxious aspects of web development: dealing with search params (sometimes called querystrings). Basically the stuff that comes after the?
in a URL:/tasks?search=foo&status=open
. The underlying platform primitive URLSearchParams
can be tedious to work with, and frameworks donât usually do much better, often providing you an un-typed bag of properties, and offering minimal help in constructing a new URL with new, updated querystring values.
TanStack Router provides a convenient, fully-featured mechanism for managing search params, which are also type-safe. Letâs dive in. Weâll take a high-level look, but the full docsare here.
Weâll add search param support for the/epics/$epicId/milestones
route. Weâll allow various values in the search params that would allow the user to search milestones under a given epic. Weâve seen the createFileRoute
function countless times. Typically we just pass a component
to it.
export const Route = createFileRoute("/epics/$epicId/milestones/")({
component: ({}) => {
// ...
Thereâs lots of other functions it supports. For search params we wantvalidateSearch
. This is our opportunity to tell Routerwhichsearch params this route supports, and how to validate whatâs currently in the URL. After all, the user is free to type whatever they want into a URL, regardless of the TypeScript typings you set up. Itâs your job to take potentially invalid values, and project them to something valid.
First, letâs define a type for our search params.
type SearchParams = {
page: number;
search: string;
tags: string[];
};
Now letâs implement ourvalidateSearch
method. This receives aRecord<string, unknown>
representing whatever the user has in the URL, and from that, we return something matching our type. Letâs take a look.
export const Route = createFileRoute("/epics/$epicId/milestones/")({
validateSearch(search: Record<string, unknown>): SearchParams {
return {
page: Number(search.page ?? "1") ?? 1,
search: (search.search as string) || "",
tags: Array.isArray(search.tags) ? search.tags : [],
};
},
component: ({}) => {
Note that (unlikeURLSearchParams
) we are not limited to just string values. We can put objects or arrays in there, and TanStack will do the work of serializing and de-serializing it for us. Not only that, but you can even specifycustom serialization mechanisms.
Moreover, for a production application, youâll likely want to use a more serious validation mechanism, like Zod. In fact, Router has a number of adapters you can use out of the box, including Zod. Check out the docson Search Params here.
Letâs manually browse to this path, without any search params, and see what happens. When we browse to
http://localhost:5173/epics/1/milestones
Router replaces (does not redirect) us to:
http://localhost:5173/epics/1/milestones?page=1&search=&tags=%5B%5D
TanStack ran our validation function, and then replaced our URL with the correct, valid search params. If you donât like how it forces the URL to be âuglyâ like that, stay tuned; there are workarounds. But first letâs work with what we have.
Weâve been using theRoute.useParams
method multiple times. Thereâs also a Route.useSearch
that does the same thing, for search params. But letâs do something a little different. Weâve previously been putting everything in the same route file, so we could just directly reference the Route object from the same lexical scope. Letâs build a separate component to read, and update these search params.
Iâve added aMilestoneSearch.tsx
component. You might think you could just import the Route
object from the route file. But thatâs dangerous. Youâre likely to create a circular dependency, which might or might not work, depending on your bundler. Even if it âworksâ you might have some hidden issues lurking.
Fortunately Router gives you a direct API to handle this,getRouteApi
,which is exported from @tanstack/react-router
. We pass it a (statically typed) route, and it gives us back the correct route object.
const route = getRouteApi("/epics/$epicId/milestones/");
Now we can calluseSearch
on that route object and get our statically typed result.

We wonât belabor the form elements and click handlers to sync and gather new values for these search parameters. Letâs just assume we have some new values, and see how we set them. For this, we can use theuseNavigate
hook.
const navigate = useNavigate({
from: "/epics/$epicId/milestones/"
});
We call it and tell it where weâre navigatingfrom. Now we use the result and tell it where we want togo(the same place we are), and are given asearch
function from which we return the new search params. Naturally, TypeScript will yell at us if we leave anything off. As a convenience, Router will pass this search function the current values, making it easy to just add / override something. So to page up, we can do
navigate({
to: ".",
search: prev => {
return { ...prev, page: prev.page + 1 };
},
});
Naturally, thereâs also aparams
prop to this function, if youâre browsing to a route with path parameters that you have to specify (or else TypeScript will yell at you, like always). We donât need an$epicId
path param here, since thereâs already one on the route, and since weâre going to the same place we already are (as indicated by thefrom
value in useNavigate
, and theto: "."
value in navigate function) Router knows to just keep whatâs there, there.
If we want to set a search value and tags, we could do:
const newSearch = "Hello World";
const tags = ["tag 1", "tag 2"];
navigate({
to: ".",
search: prev => {
return { page: 1, search: newSearch, tags };
},
});
Which will make our URL look like this:
/epics/1/milestones?page=1&search=Hello%20World&tags=%5B"tag%201"%2C"tag%202"%5D
Again, the search, and the array of strings were serialized for us.
If we want tolink toa page with search params, we specify those search params on the Link tag
<Link
to="/epics/$epicId/milestones"
params={{ epicId }}
search={{ search: "", page: 1, tags: [] }}>
View milestones
</Link>
And as always, TypeScript will yell at us if we leave anything off. Strong typing is a good thing.
Making Our URL Prettier
As we saw, currently, browsing to:
http://localhost:5173/epics/1/milestones
Will replace the URL with this:
http://localhost:5173/epics/1/milestones?page=1&search=&tags=%5B%5D
It will have all those query params since we specifically told Router that our page will always have a page, search, and tags value. If you care about having a minimal and clean URL, and want that transformation tonothappen, you have some options. We can make all of these values optional. In JavaScript (and TypeScript) a value does not exist if it holds the valueundefined
. So we could change our type to this:
type SearchParams = {
page: number | undefined;
search: string | undefined;
tags: string[] | undefined;
};
Or this which is the same thing:
ype SearchParams = Partial<{
page: number;
search: string;
tags: string[];
}>;
Then do the extra work to put undefined values in place of default values:
validateSearch(search: Record<string, unknown>): SearchParams {
const page = Number(search.page ?? "1") ?? 1;
const searchVal = (search.search as string) || "";
const tags = Array.isArray(search.tags) ? search.tags : [];
return {
page: page === 1 ? undefined : page,
search: searchVal || undefined,
tags: tags.length ? tags : undefined,
};
},
This will complicate places where youusethese values, since now they might be undefined. Our nice simple pageUp call now looks like this
navigate({
to: ".",
search: prev => {
return { ...prev, page: (prev.page || 1) + 1 };
},
});
On the plus side, our URL will now omit search params with default values, and for that matter, our<Link>
tags to this page now donât have to specifyanysearch values, since theyâre all optional.
Another Option
Router actually provides you another way to do this. Currently validateSearch
accepts just an untypedRecord<string, unknown>
since the URL can contain anything. The âtrueâ type of our search params is what wereturnfrom this function. Tweaking thereturn typeis how weâve been changing things.
But Router allows you to opt into another mode, where you can specifybotha structure of incoming search params, with optional values,as well asthe return type, which represents the validated, finalized type for the search params that will beusedby your application code. Letâs see how.
First letâs specify two types for these search params
type SearchParams = {
page: number;
search: string;
tags: string[];
};
type SearchParamsInput = Partial<{
page: number;
search: string;
tags: string[];
}>;
Now letâs pull in SearchSchemaInput
:
import { SearchSchemaInput } from "@tanstack/react-router";
SearchSchemaInput
is how we signal to Router that we want to specify different search params for what weâllreceivecompared to what weâllproduce. We do it by intersecting our desired input type with this type, like this:
validateSearch(search: SearchParamsInput & SearchSchemaInput): SearchParams {
Now we perform the same original validation we had before, to produce real values, and thatâs that. We can now browse to our page with a<Link>
tag, and specify no search params at all, and itâll accept it and not modify the URL, while still producing the same strongly-typed search param values as before.
That said, when weupdateour URL, we canât just âsplatâ all previous values, plus the value weâre setting, since those params will now have values, and therefore get updated into the URL. The GitHub repo has a branch called feature/optional-search-params-v2
(arackaf/tanstack-router-routing-demo
) showing this second approach.
Experiment and choose what works best for you and your use case.
Wrapping up
TanStack Router is an incredibly exciting project. Itâs a superbly-made, flexible client-side framework that promises fantastic server-side integration in the near future.
Weâve barely scratched the surface. We just covered the absolute basics of type-safe navigation, layouts, path params, and search params, but know there is much more to know, particularly around data loading and the upcoming server integration.
Article Series