TanStack Router is an incredibly exciting project. It’s essentially a fully-featured client-side JavaScript 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 a type-safe manner.
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.
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 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 the routes
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>
</>
);
},
});
Code language: JavaScript (javascript)
The createRootRoute
function does what it says. The <Link />
component is also fairly self-explanatory (it makes links). Router is kind enough to add an active
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 with npm run dev.
Check your terminal for the port on localhost
where it’s running.
More importantly, the dev
watch process monitors the routes we’ll be adding, and maintains a routeTree.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, 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">
Code language: HTML, XML (xml)
Building the App
Let’s get started by adding our root page. In Router, we use the file index.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>,
});
Code language: JavaScript (javascript)
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 everything just works. In TanStack Router we have have to call a function called createFileRoute
, 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 called junk
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>
);
}
Code language: JavaScript (javascript)
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 a tasks.index.tsx
file. This is the same as creating an index.tsx
inside of an hypothetical tasks
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>
);
}
Code language: JavaScript (javascript)
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 a tasks
folder, we’d just throw a route.tsx
file in there. Instead, we’ll add a tasks.route.tsx
file. Since we’re using flat files, here, we can also just name it tasks.tsx
. But I like keeping things consistent with directory-based files (which we’ll see in a bit), so I prefer tasks.route.tsx
.
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/tasks")({
component: () => (
<div>
Tasks layout <Outlet />
</div>
),
});
Code language: JavaScript (javascript)
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. And xyz.index.tsx
is the file for the individual path at xyz
.
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 see Tasks 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 add tasks.$taskId.index.tsx
and tasks.$taskId.edit.tsx
. The former will route to /tasks/123
and the latter will route to /tasks/123/edit
. Let’s take a look at tasks.$taskId.index.tsx
and find out how we actually get the path parameter that’s passed in.
import { 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>
);
},
});
Code language: JavaScript (javascript)
The Route.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 }}>
Code language: JavaScript (javascript)
if we’d left off the params here (or specified anything other than taskId
), 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 that tasks
became tasks_
. We do need tasks
in there, where it is, so Router will know how to eventually find the edit.tsx
file we’re rendering, based on the URL. But by naming it tasks_
, we remove that component from the rendered component tree, even though tasks
is still in the URL. Now when we render the edit task route, we get this:
Notice how Tasks layout
is gone.
What if you wanted to do the opposite? What if you have a component hierarchy you want, that is, you want some layout to render in the edit task page, but you don’t want 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 the component hierarchy. Let’s say we have a special layout we want to 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>
),
});
Code language: JavaScript (javascript)
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. I usually prefer 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 best to 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 an epics
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 than epics.index.tsx
and epics.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 docs are 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: ({}) => {
// ...
Code language: JavaScript (javascript)
There’s lots of other functions it supports. For search params we want validateSearch
. This is our opportunity to tell Router which search 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[];
};
Code language: TypeScript (typescript)
Now let’s implement our validateSearch
method. This receives a Record<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: ({}) => {
Code language: TypeScript (typescript)
Note that (unlike URLSearchParams
) 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 specify custom 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 docs on 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 the Route.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 a MilestoneSearch.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/");
Code language: JavaScript (javascript)
Now we can call useSearch
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 the useNavigate
hook.
const navigate = useNavigate({
from: "/epics/$epicId/milestones/"
});
Code language: JavaScript (javascript)
We call it and tell it where we’re navigating from. Now we use the result and tell it where we want to go (the same place we are), and are given a search
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 };
},
});
Code language: JavaScript (javascript)
Naturally, there’s also a params
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 the from
value in useNavigate
, and the to: "."
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 };
},
});
Code language: JavaScript (javascript)
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 to link to a 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>
Code language: JavaScript (javascript)
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 to not happen, 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 value undefined
. So we could change our type to this:
type SearchParams = {
page: number | undefined;
search: string | undefined;
tags: string[] | undefined;
};
Code language: TypeScript (typescript)
Or this which is the same thing:
type SearchParams = Partial<{
page: number;
search: string;
tags: string[];
}>;
Code language: TypeScript (typescript)
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,
};
},
Code language: TypeScript (typescript)
This will complicate places where you use these 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 };
},
});
Code language: JavaScript (javascript)
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 specify any search values, since they’re all optional.
Another Option
Router actually provides you another way to do this. Currently validateSearch
accepts just an untyped Record<string, unknown>
since the URL can contain anything. The “true” type of our search params is what we return from this function. Tweaking the return type is how we’ve been changing things.
But Router allows you to opt into another mode, where you can specify both a structure of incoming search params, with optional values, as well as the return type, which represents the validated, finalized type for the search params that will be used by 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[];
}>;
Code language: TypeScript (typescript)
Now let’s pull in SearchSchemaInput
:
import { SearchSchemaInput } from "@tanstack/react-router";
Code language: JavaScript (javascript)
SearchSchemaInput
is how we signal to Router that we want to specify different search params for what we’ll receive compared to what we’ll produce. We do it by intersecting our desired input type with this type, like this:
validateSearch(search: SearchParamsInput & SearchSchemaInput): SearchParams {
Code language: TypeScript (typescript)
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 we update our 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
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.