When I was learning React around 6 years ago, react-router was one of the first third-party libraries I picked up. I mean, it makes sense: routing is one of the key aspects of modern Single Page Application. I took it for granted. For me, react-router was a magic box, I had no idea how it works internally. So when after some time I naturally figured out how routing works in general, it was a bit sad. No more magic, huh :(
But I'm glad I left that concept of "magic black boxes" behind. I think it's a really harmful idea. Undestanding that every piece of tech ever has been built by engineers, just like you and me, inspires me a lot, and I hope it does the same for you.
So join me in this post where we'll build our own typesafe router from scratch to open that black box and understand its inner workings. This article assumes you already know React and comfortable with TypeScript.
Table of contents
- Features and limitations
- Terminology
- High-level overview of API
- Type-level programming for fun and profit
- Route parser
- History wrapper
- Step 1: Imperative API
- Step 2: Hooks
- Step 3: Components
- Putting the puzzle pieces together
- Possible improvements
Features and limitations
Let me outline which features of our router will be covered in this article.
Our killer feature will be typesafety. This means you'll need to define your routes in advance and TypeScript will check that you don't pass some nonsense instead of URL or try to get parameters that are missing in current route. This will require some type gymnastics, but don't worry, I'll walk you through.
Besides that, our router will support everything you'd expect from average router: navigation to URLs, route matching, route parameters parsing, back/forward navigation, and navigation blocking.
Now, about limitations. Our router will work only in browser (sorry React Native!) and it won't support SRR. SSR support should be relatively easy, but this post is already huge so I won't cover it.
Terminology
Now that we have a vision of what we'd like to make, we need to talk about stucture and terminology. There will be quite a lot of similar concepts, so defining them in advance is crucial.
There will be two kinds of routes in our library: raw routes and parsed routes. Raw route is just a string that looks like /user/:id/info
or /login
, it's a template for URL. Raw route can contain parameters, which are sections that start with a colon, like :id
. This format is easy to use for developers, but not so much for a program, we'll transform those routes into a more machine-friendly format and call it parsed route.
But users don't open routes, they open URLs. And URL is an identifier for resource (page inside the app in our case), it might look like http://example.app/user/42/info?anonymous=1#bio
. Our router mostly cares about the second part of the URL (/user/42/info?anonymous=1#bio
), that we'll call path. Path consists of pathname (/user/42/info
), search parameters (?anonymous=1
) and hash (#bio
). The object that stores those components as separate fields will be called location.
High-level overview of API
When building React libraries, I like to go in three steps. First of all, imperative API. Its functions can be called from any place and they're usually not tied to React at all. In case of a router, that will be functions like navigate
, getLocation
or subscribe
. Then, based on those functions, hooks like useLocation
or useCurrentRoute
are made. And then those functions and hooks are used as a base for building components. This works exceptionally well, in my experience, and allows for making an easily extendable and versatile library.
Router's API starts with defineRoutes
function. The user is supposed to pass a map of all raw routes to the function, which parses the routes and returns a mapping with the same shape. All developer-facing APIs, like URL generation or route matching, will accept parsed routes and not raw.
Next step is to pass parsed routes to createRouter
function. This is the meat of our router. This function will create all of the functions, hooks and components. This might look unusual, but such structure alows us to tailor the types of accepted arguments and props to a specific set of routes defined in routes
, ensuring typesafety (and improving DX).
createRouter
will return functions which can be used anywhere in your app (imperative API), hooks which allow your components to react to location changes and three components: Link
, Route
and NotFound
. This will be enough to cover the majority of use-cases and you can build your own components based on those APIs.
Type-level programming for fun and profit
We start by tackling the typesafety part of our pitch. As I mentioned before, with typesafe router TypeScript will warn you in advance about a situation like this:
Or like this:
And if you can't see right away what's wrong with these, you definitely need a typesafe router :)
The type system in TypeScript is very powerful. I mean, you can make a chess engine, an adventure game or even an SQL database using type-level programming.
You're already familiar with 'value-level programming' where you manipulate values, e.g. concatenating two strings:
But you can do it with types too!
Yes, it's not as powerful as your ordinary functions and looks different. But it allows you to do some pretty cool and useful stuff. We'll use type-level programming to extract parameters from raw routes and build new types which can check that developer doesn't try to pass incorrect values to Link
or to the function that's constructing the URL.
Type-level programming with TS might quickly become an unreadable mess. Fortunately for us, there are already multiple projects that hide all this complexity away and allow us to write clean code like this:
Pretty neat, huh? That, by the way, is all code you need to parse parameters from raw route. In this project we'll use hotscript library - it will help us reduce complexity and the amount of type-level code. But it's not required: if you feel adventurous, you can try implementing all these types yourself. You can find some inspiration in Chicane router, which implements similar features without using third-party type libraries.
If you're going to follow along, I recommend you create a new React project using your favorite starter (I use Vite) and start coding there. This way you'll be able to test your router right away. Please note that frameworks like Next.js provide their own routing which can interfere with this project, and use 'vanilla' React instead. If you have any difficulties, you can find complete code here.
Start by installing third-party packages: hotscript for type-level utilities and regexparam for parsing parameters from URL/raw route.
The first building brick of our types is raw route. Raw route should start with /
, how would you code that in TS? Like this:
Easy, right? But defineRoutes
doesn't accept a single raw route, it accepts mapping, possibly nested, so let's code that. You might be tempted to write something like this:
This will work. However, this type can be infinitely deep and TS will have a hard time calculating it in some instances. To make TS's life easier we'll limit the level of allowed nesting. 20 nesting levels should be plenty for all apps and TS can handle that easily. But how do you limit the depth of recursive types? I learned this trick from this SO answer, here is a version modified for our requirements:
This is our first complex type, so let me explain it. We have two types here: RecursiveMap
works as entry point and calls RecursiveMap_
passing it an additional tuple parameter. This tuple is used to track the depth of the mapping, with every call we add one element to this array. And we continue to call it until the length of this tuple is equal to MaxDepth
. In TS, when extends
is used with specific values, also called literals (e.g. specifically 42
, not number
), it means 'equal'. And since both MaxDepth
and Stack["length"]
are specific numbers, this code can be read as MaxDepth === Stack["length"]
. You will see this construction being used a lot.
Why use tuple instead of just adding numbers? Well, it's not that easy to add two numbers in TypeScript! There is a whole library for that, and Hotscript can add numbers too, but it requires a lot of code (even if you don't see it), which can slow your TS server and code editor, if used excessively. So my rule of thumb is to avoid complex types as much as reasonably possible.
With this utility type, we can define our mapping as simple as:
That's all for raw route types. Next in the queue is parsed route. Parsed route is just a JavaScript object with a few additional fields and one function, here's how it looks:
Let's start unpacking this from the keys
field. It's simply an array of parameters which are required for this route. Here is how its done:
In Hotscript, there are two ways to call a function: Call
or Pipe
. Call
is useful when you need to call a single function, but in our case we have 4 of them! Pipe
accepts input and, well, pipes it into the first function of a provided tuple. Returned value is passed as input into the second function and so on. In our case, if we had, for example, raw route /user/:userId/posts/:postId
, it will be transformed like this:
See? This is the magic of type-level programming! Now let's tackle that build
function. It accepts route parameters (like userId
and postId
) and optional search params/hash, and combines them into a path. Take a look at a PathConstructorParams
implementation:
Function parameters are defined as an array (which is later ...spread in the definition of the build
function), where the first element is RouteParamsMap
and the second is optional SearchAndHashPathConstructorParams
. What about returning the value of build
? We already established its path, but how do you describe it with TypeScript? Well, this one is quite similar to RouteParam
, but requires a bit more of type gymnastics!
What we do here is split our route into segments, map over each segment and call our custom function ReplaceParam
on each. It checks if the current segment is a parameter and replaces it with string
or returns the segment as-is. ReplaceParam
'function' might look a bit weird, but that's how you define custom functions with Hotscript. We explicitly state that path consists either from just path, path followed by question mark (this covers URLs with search params and hash) or hash symbol (this covers URLs without search params, but with hash).
We'll also need a type to describe matched route, i.e. parsed route with parameters captured from URL:
Last type is ParsedRoutesMap
, it's similar to RawRoutesMap
, but for, well, parsed routes.
And on that note, we finish with types. There will be a few more, but they are simpler and we'll cover them as we go with the implementation. If type-level programming is something you'd like to try more of, you can check out Type-level Typescript to learn more and try to solve type-challenges (they have a good list of resources too).
Route parser
Finally we're back to regular value-level coding. Let's get the ball rolling by implementing defineRoutes
.
Nothing complex here, let's dive deeper into parseRoute
function.
parseRoute
is also very simple, albeit noticeably longer. To parse route and extract parameters we use regexparam
library. It allows us to get an array of parameters required for route, and generates a regular expression which we'll later use to match the URL with route. We store this info along with original raw route used to construct this object and ambiguousness level (which is just a number of parameters in route).
History wrapper
Every router has to store its state somewhere. In case of apps in browser, that really boils down to 4 options: in-memory (either in a state variable inside the root component or in a variable outside of the components tree), History API or hash part of the URL. In-memory routing might be your choice if you don't want to show the user you have routes at all, for example if you're coding a game in React. Storing the route in hash can be handy when your React app in only one page in a bigger application and you can't just change the URL however you want.
But for most cases using History API will be the best option. It's compatible with SSR (other options aren't), follows behavior patterns user is accustomed to and just looks cleanest. In this project we'll be using it too. It has one notable flaw though: it's mostly unusable without additional wrappers.
With History API you can subscribe to popstate
event and the browser will let you know when the URL changes. But only if the change is initiated by user by, for example, clicking on the back button. If URL change is initiated from code, you need to keep track of it yourself. Most of routers I studied use their own wrapper: react-router and chicane use history NPM package, TanStack router has its own implementation and wouter doesn't have a full-fledger wrapper, but still has to monkey-patch history.
So let's implement our own wrapper.
There are two types we'll use, HistoryLocation
and NavigationBlocker
. First is a bit limited version of the built-in Location
type (that's the type of window.location
), and second will be covered once we get to navigation blocking. All further code from this chapter will go inside createHistory
function.
Let's start with implementing a subscription to history changes. We'll use React-style functions for subscribing in this project: you call subscribe
passing a callback and it returns another function which you need to call when you want to unsubscribe.
Next step is to react to location changes, including changes made programmatically. How would you do it? With monkey-patching of course. That might look a bit dirty (and it really is), but we don't have better options, unfortunately.
And the last major missing piece in our history implementation is navigation blocking: a feature which allows you to intercept the navigation request and conditionally cancel it. A canon example of navigation blocking would be preventing the user from losing their progress in a form.
In our implementation, a blocker is a function that returns a boolean indicating whether we need to block this navigation. In regards to navigation blocking, there are two types of navigation and we'll need to handle them differently.
On one hand, there is soft navigation - when the user navigates from one page in our app to another page in our app. We fully control it and thus can block it, display any custom UI (to confirm user's intent) or perform actions after blocking the navigation.
On the other hand, there is hard navigation - when the user navigates to another site or closes the tab altogether. Browser can't allow JavaScript to decide if this navigation should be performed, as it will be a security concern. But the browser allows JavaScript to indicate if we want to show an extra confirmation dialog to user.
When blocking soft navigation, you might want to display additional UI (e.g. custom confirmation dialog), but in case of hard navigation, it doesn't really make sense as the user will only see it if they decide to remain on the page and, at that point, it's useless and confusing. When our history calls the navigation blocker function, it will provide a boolean, indicating if we're performing soft navigation.
And with all that, we just need to return our history object:
Step 1: Imperative API
We're finally here. Imperative API will be the base for all further hooks and components and will allow the developer to build custom hooks to cover their needs. First of all, we need to transform our routes map into a flat array. This way, it will be a lot easier to loop over all routes, which will come in handy when we start working on the route matching part. We need both type utility (which will transform ParsedRoutesMap
into union of ParsedRoute
) and function (which will transform routesMap
into an array of parsed routes). Let's start with type:
It might look unnecessary to split this into two types, but there is one very important reason for it: if you implement it as a single type that calls itself, TS will complain that the type is excessively deep and possibly infinite. So we work around this by splitting it into two types that call eachother.
For a value-level function we'll also need a type guard to check if the passed value is a parsed route.
Now, let's start implementing our router. Like with history, in this and the next two chapters all code will go into the createRouter
function, unless stated otherwise.
First of all, let's teach our router to match current location to one of the known routes. A couple of utilities can go into global scope or separate file, not inside the createRouter
function:
And this code goes into the createRouter
function.
Here we go over all known routes and try to match each one to the current location. If the route's regex matches URL — we get route parameters from URL, otherwise we get null
. For each matched route we create a RouteWithParams
object and save it to an array. Now, if we have 0 or 1 matching routes, everything is simple. However, if more than one route matches the current location, we have to decide which one has higher priority. To solve this, we use ambiguousness
field. As you might remember, it's a number of parameter this route has, and a route with the lowest ambiguousness
is prioritized.
For example, if we had two routes /app/dashboard
and /app/:section
, location http://example.com/app/dashboard
would match both routes. But it's pretty obvious that this URL should correspond to /app/dashboard
route, not /app/:section
.
This algorithm isn't bulletproof though. For example, routes /app/:user/settings/:section
and /app/dashboard/:section/:region
both will match URL http://example.com/app/dashboard/settings/asia
. And since they have the same ambiguousness level, our router won't be able to decide which one should be prioritized.
Now we need to glue this code together to react to location changes and update currentRoute
variable;
Now our router reacts to location changes and the user can always get the current route, yay! But it's not very useful without the ability to subscribe to route changes, so let's add that. The approach is very similar to the one we used in history wrapper.
To perform navigation, we'll expose navigate
and navigateUnsafe
functions, which are a simple wrapper around history.push
and history.replace
:
Well, now that's a real router! Very bare-bones, but working nonetheless. We still have some hooks and components to implement, but it gets much easier from here.
Step 2: Hooks
For hooks, we can start with simple ones that return current location and current route. They are quite easy on their own, but useSyncExternalStore turns them into a one-liner. The way we designed our imperative API earlier allowed us to drastically reduce code for these hooks.
When coding components that are supposed to be rendered only on specific route/set of routes, you can use useCurrentRoute
to get the current route, check if it matches the criteria and then use its parameters (or throw an error). But this is such a common task that it will be a crime to make our users write their own hook for that - our router should provide this out of the box.
This hook has two versions: strict and relaxed. If user passes true
as second parameter (or doesn't pass anything, as true
is the default value) this hook will throw an error if the current route doesn't match one of the provided filters. This way you can be sure that the hook will return a matching route or not return at all. If the second parameter is false, instead of throwing an exception, the hook will simply return undefined if the current route doesn't match filters.
To describe this behavior to TypeScript we use a feature called function overloading. This allows us to define multiple function definitions with different types, and TypeScript will automatically pick one to be used when the user calls such function.
In addition to path parameters, some data might be passed in search parameters, so let's add a hook to parse them from string into mapping. For this we'll use built-in browser API URLSearchParams.
And the last hook in this section is useNavigationBlocker
which is also quite simple: it will just accept callback and wrap calls to history.addBlocker
into an effect, to re-attach the blocker if it changes.
Now let's jump into components!
Step 3: Components
What is the first component that comes to mind when mentioning routing libraries? I bet it's Route
or, at least, something similar. As you saw previously, our hooks were very simple due to a well-designed imperative API which does all the heavy lifting. Same goes for components, they can be easily implemented by the user in a few lines of code. But we're a serious routing library out there, let's include batteries in the box :)
Well, that was easy! Wanna guess how we implement the NotFound
component? :)
And the last component required for our router is Link
, which is a bit more tricky. You can't just use <a href="/app/dashboard" />
as it will always initiate hard navigation and doesn't provide any typesafety. So let's address these issues:
Similarly to navigate
function, the Link
component typechecks the URL that you pass to it, but also allows to provide an arbitrary string URL (as an escape hatch or for external links). To override <a>
's behavior, we attach our own onClick
listener, inside which we'll need to call the original onClick
(passed to our Link
component). After that, we check if the navigation wasn't already aborted by developer (if it was, we should ignore the event). If all is good, we check if the link isn't external and if it should be open in current tab. And only then we cancel the built-in hard navigation and instead call our own navigateUnsafe
function.
And now, we just need to return all our functions, hooks and components (along with a few functions re-exported directly from history) from the createRouter
function.
And with that, our router is done. Now we can build our teeny-weeny app to showcase our teeny-weeny router we just made! By the way, you can find the complete code for this router (including the code for an example app) here.
Putting the puzzle pieces together
So, how does all this code tie up together? Quite neatly, if I do say so myself! Imagine you're making a note-taking app. Firstly, you would start with defining the routes and creating a router like this:
And then you link the routes you've defined with the page components:
When in NoteDetailsPage
, you need to get a note ID from the url, so you use a useRoute
hook:
And when creating a new note, you probably would like to confirm the user's intent if they navigate away without saving the note:
Possible improvements
While our router does indeed route, it's no match for production-ready solutions like TanStack router, react-router or Next.js router. I mean it's just ~500 lines of code, that's not much. But what exactly is missing?
First of all, Server Side Rendering. Today not all apps might need SSR, but all routing libraries are expected to support it. Adding server side rendering into a string (not streaming SSR!) will involve creating a different history
which will store the current location in memory (as there is no History API on server) and plug that into the createRouter
function. I'm not aware about how hard it will be to implement streaming SSR, but I assume it will be strongly connected with support of Suspense.
Second, this router doesn't integrate with concurrent rendering well. Mostly because of our use of useSyncExternalStore
, as it isn't compatible with non-blocking transitions. It works this way to avoid tearing: a situation where a part of UI has rendered with a particular store value, but the rest of the UI is rendered with a different one. And because of this, the router doesn't integrate well with Suspense, as for every location update that suspends, a fallback will be shown. By the way, I covered concurrency in React in this article and in this one I talk about Suspense, data fetching and use
hook.
But even with these downsides, I hope you found this article interesting and built your own router along the way :)