Implement a custom router in Next.js (Part 1)


Next.js Logo

In this post I'm going to show you how I usually handle routes in Next.js (and plain React projects)

First of all, it is worth mentioning that this approach introduces extra complexity so I don't use it for every project. In this site, for example, it isn't really necessary since it just has a few pages. I recommend this approach for medium-big projects, although if you think your project is not too big I encourage you to keep reading since this initial part can still be useful for you as it is for me in almost every project.

The problem

In every frontend project we have to deal with routes. Handling them can become a little cumbersome if we don't pay attention to routes in the early stages. Imagine a project with twenty or more routes and referencing them directly by their name as a plain string. Now imagine if one of the routes changes. Can you see the problem? Yes, we have to find all the places where that route is referenced and update it.

This can lead to various issues. You may forget to update a route which will redirect to a non-existent page. Or if you are generating files such as sitemap.xml or using any other type of metadata that requires the route name, it would be outdated.

Solution

To solve this problem let's rely on the well-known "Don't repeat yourself" approach and move those hardcoded strings to constants.

What I usually do to start is create a new folder in my projects called config where I place a routes.ts file within it. The reason I create a config folder is because I usually also end up using it to store other configuration files later. But, if preferred, you can also just create an individual routes.ts file.

// config/routes.ts
 
export const ROUTES = {
  home: "/",
  aboutUs: "/about-us",
  // More routes...
};

After that we replace all the ocurrences in code by their constants.

Before

<Link href='/'>Home</Link>
<Link href='/about-us'>About Us</Link>
 
// Or calls to the router push / relace functions
 
<button type='button' onClick={() => push('/home')}>
  Home
</button>
<button type='button' onClick={() => push('/about-us')}>
  About Us
</button>

After

import { ROUTES } from '@config/routes';
 
<Link href={ROUTES.home}>Home</Link>
<Link href={ROUTES.aboutUs}>About Us</Link>
 
// Or calls to the router push / relace functions
 
<button type='button' onClick={() => push(ROUTES.home)}>
  Home
</button>
<button type='button' onClick={() => push(ROUTES.aboutUs)}>
  About Us
</button>

Note: I always define TypeScript aliases in my projects to simplify the imports. That's the reason for the @ in the import

So far nothing too interesting and revolutionary, I know. We simply replaced some hardcoded texts by constants so let's continue evolving this.

Dynamic Routes

You are probably now wondering about dynamic routes? How can we add the necessary parameters to them?

Let's take a look at the following route:

https://jsettecase.me/posts/implement-a-custom-router-in-next-js

As you can see, the last part of the URL is a dynamic parameter that corresponds to the title of the post sanitized for that purpose. If we want to redirect to it from within the site (for example from the posts list to the actual post) we would need to concatenate the dynamic parameter manually to the route as follows.

// Posts coming from DB or filesystem
const posts = [
  {
    title: "Implement a custom router in Next.js (Part 1)",
    url: "implement-a-custom-router-in-next-js",
  },
];
 
posts.map((post) => (
  <Link href={`/posts/${post.url}`}>
    {post.title}
  </Link>
));

Note: I'm going to use the Link component as an example from now on but this is the same for the push / replace functions of the router

As of now, with the change we just made by moving the hardcoded routes to a constant, we still would need to do the same but with the constants instead.

import { ROUTES } from "@config/routes";
 
const posts = [
  {
    title: "Implement a custom router in Next.js (Part 1)",
    url: "implement-a-custom-router-in-next-js",
  },
];
 
posts.map((post) => (
  <Link href={`${ROUTES.posts}/${post.url}`}>{post.title}</Link>
));

For some of you, this approach may be sufficient. But having to concatenate all the necessary parameters every time can be a bit annoying. In this case, we just have one parameter, but we could have more than one. For that reason, I like to implement the following solution.

In the routes.ts file add this new function:

// config/routes.ts
 
export type AvailableRoutes = keyof typeof ROUTES
 
export function getRoute(
  routeKey: AvailableRoutes,
  params: Record<string, string> = {}
): string {
  let route = ROUTES[routeKey]
  for (const [k, v] of Object.entries(params)) {
    route = route.replace(`:${k}`, v)
  }
 
  return route
}

The getRoute function takes two arguments, the first one must be one of the keys defined in our ROUTES object, the second one is an object containing the parameters we would like to replace in the route value. We are using colon to define a dynamic parameter in a route, e.g: /posts/:url but you can choose your own by modifying the route.replace() line.

Now we can define our dynamic routes as follows:

// config/routes.ts
 
export const ROUTES = {
  home: "/",
  aboutUs: "/about-us",
  posts: "/posts/:url",
  // More routes...
};

And finally instead of importing the ROUTES constant we can use the getRoute function which slightly simplifies the use of our routes and passing parameters to them:

import { getRoute } from "@config/routes";
 
const posts = [
  {
    title: "Implement a custom router in Next.js (Part 1)",
    url: "implement-a-custom-router-in-next-js",
  },
];
 
posts.map((post) => (
  <Link href={getRoute("posts", { url: post.url })}>
    {post.title}
  </Link>
));

Note: Keep in mind that we are not reintroducing hardcoded routes. The first parameter of the function is the key in the ROUTES object, not the value, so if in the future we change the route value, we don't have to change the calls to it. Also, if you are using TypeScript (and you should be 😉), it will prevent you from passing a wrong string to the getRoute function since it is well-typed and if you change some key in the ROUTES object, TypeScript will tell you all the places where you have to change it as well.

Link Component

If you use the Link component, you can create a wrapper around it and reuse the getRoute function previously created as follows:

import LinkComponent, { LinkProps } from "next/link";
import { AnchorHTMLAttributes, PropsWithChildren } from "react";
import { AvailableRoute, getRoute } from "@config/routes";
 
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> & {
  href: AvailableRoute;
};
 
export function Link({ href, children, ...props }: PropsWithChildren<Props>) {
  return (
    <LinkComponent href={getRoute(href)} {...props}>
      {children}
    </LinkComponent>
  );
}

Note: The Link component has actually more types associated to it but get the main idea here. You can keep improving this.

And now, instead of using the Link component coming from next/link package, you can use your wrapper previously created.

Summary

This part of the approach is just a pre-work that I like to do in order to simplify things before adding the custom router. Not having to harcode routes and having TypeScript letting us know about potential issues is a huge win. I think you can take advantage of this regardless of the size of your project.

In the second part of these series, we'll introduce the custom router and we'll make use of the getRoute function. Stay tuned!