DEV Community

Antoni Silvestrovič
Antoni Silvestrovič

Posted on • Originally published at blog.antoni.ai on

routes.ts

Introduction

Tracking all the routes you have in your frontend application can be difficult.
The first time developers need to link between pages usually results in URLs
that are defined inline, just passed as an href to the link component. This
results in a lot of problems later on when someone needs to update the route
path, or when someone accidentally mistypes the link URL. With time developers
start to realise that there’s a better way to deal with accessing URLs in their
applications.

In this guide I’m aiming document my experience with defining web app routes.
There are a couple of fundamental principles that you should keep in mind when
defining routes:

  • JavaScript/TypeScript definition should describe what URL you will get.
  • Don’t be afraid to make it look complex if it simplifies life of it’s user.
  • Try to go as static as possible. Depending on lifetime hooks is usually a bad idea.

Basic example

Lets start with the most simple example you will need. If your application does
not have any dynamic routes, this might be all you will need.

const ROUTES = {
  index: "/",
  books: "/books"
  users: {
    index: "/users",
    me: "/users/me",
  },
  externalLink: "https://quassum.com/"
}

ROUTES.books       // -> /books
ROUTES.users.index // -> /users
ROUTES.users.me    // -> /users/me
Enter fullscreen mode Exit fullscreen mode

Every route level get’s their own object here. The root level routes stay in the
root level object. As soon as there are nested routes we add another object. In
this case you can see that since /users have a nested route /users/me
everything that starts with /users goes into its’ own object. For the /users
route itself we add index key that corresponds with the way browsers deal with
the index.html file.

With dynamic parameters

The next step in the route definition journey are the dynamic routes. As soon as
you will have data coming from a database you will most likely need dynamic
routes. This is solved with a simple function. With TypeScript it’s also
type-safe!

const ROUTES = {
  users: {
    index: "/users",
    byId: (id: string) => `/users/${id}`,
  }
}

ROUTES.users.byId('1234') // -> /users/1234
Enter fullscreen mode Exit fullscreen mode

Dynamic routes become easy to handle with this setup. Every time you want to
access a user by their ID you have to use byId function, which reminds you
that you need to pass an id which has to be of type string.

You can also use objects instead of regular parameters…

const ROUTES = {
  users: {
    index: "/users",
    byId: ({ id }: { id: string }) => `/users/${id}`,
  },
};

ROUTES.users.byId({ id: "1234" }); // -> /users/1234
Enter fullscreen mode Exit fullscreen mode

With multiple dynamic parameters

Naturally, it becomes slightly more complex when you have multiple dynamic
segments. But nothings that we cannot manage.

const ROUTES = {
  users: {
    index: "/users",
    byId: (id: string) => {
      return {
        bySlug: (slug: string) => `/users/${id}/${slug}`,
      };
    },
  },
};

ROUTES.users.byId("1234").bySlug("something"); // -> /users/1234/something
Enter fullscreen mode Exit fullscreen mode

Here, instead of returning the URL from byId straight away, we return an
object with another function you can call. In this case you just chain these
function calls and you get a nicely formatted URL.

Don’t forget that in these dynamic segments you are not forced to pass simple
types through parameters. You could have a function that takes a whole object
and just returns one string, joining multiple properties from that object into
url. For example, you could have URL defined form user data:

const ROUTES = {
  user: {
    publicUrl: {
      byUserData: (user: UserData) => `/u/${user.username}/${user.id}`,
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

The main thing to keep in mind here is the first principle that I’ve described
in the intro. Your structure should explain to it’s user how the URL will look
like. Try to avoid cases where you just have: userById but then return a url
that looks like this: /user/info/something/[id]/something-else

With optional dynamic parameters

Having optional parameters is also quite simple. You can achieve this by either
having optional parameters or optional property in an object. The only thing you
have to keep in mind is how you manage the cases when that parameter is not
passed in.

const joinIfPresent = (base: string, str?: string) =>
  str ? `${base}${str}` : base;

const ROUTES = {
  users: {
    index: "/users",
    byId: ({ id, slug }: { id: string; slug?: string }) =>
      `/users/${id}${joinIfPresent("/", slug)}`,
  },
};
Enter fullscreen mode Exit fullscreen mode

With mid-route dynamic parameters

This one is quite simple, but important to show. If you have a the dynamic
segment somewhere in the middle of the route. This is how you handle it.

const ROUTES = {
  users: {
    index: "/users",
    byId: ({ id }: { id: string }) => {
      return {
        settings: `/users/${id}/settings`,
        account: `/users/${id}/account`,
      };
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

With global dynamic parameters

Sometimes you need to have global parameter. In this case it’s locale of your
URL. If you provide locale, it will be available throughout the routes. The fact
that you have to pass it into the localisation strings manually might look like
not the best solution, but remember, we might have external links here, or some
links might not be localised. When doing it manually we ensure that it’s exactly
where it’s supposed to be, while enabling flexibility to modify the URL
structure.

const getRoutes = (locale?: string = "en") => {
  const l = locale === "en" ? "" : `/${locale}`;

  return {
    users: {
      index: `${l}/users`,
      byId: (id: string) => `${l}/users/${id}`,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

With hooks inside

Now, getRoutes should be preferred when it comes to providing global
variables, but sometimes it makes sense to use hooks instead. When you can only
access certain data from a hook, it might be fine to convert your getRoutes to
useRoutes and put the hook you depend on inside.

The main thing to keep in mind here is that you have to make sure that your
hooks work reliably on the server side. If you don’t, you will encounter
hydration errors, so make sure that data matches between server and client side.

const getRoutes = (locale?: string = "en") => {
  const l = locale === "en" ? "" : `/${locale}`;

  return {
    users: {
      index: `${l}/users`,
      byId: (id: string) => `${l}/users/${id}`,
    },
  };
};

const useRoutes = () => {
  const locale = useLocale();
  const l = locale == "en" ? "" : `/${locale}`;

  return getRoutes(l);
};

// In your component
const YourComponent = () => {
  const routes = useRoutes();

  // when locale is "de", then it links to: /de/users/1234
  return <a href={routes.users.byId("1234")} />;
};
Enter fullscreen mode Exit fullscreen mode

Summary

The examples in this guide offer most of the use cases you will need for a
centralised route definition. Feel free to use this guide as a look up
cheatsheet.

Top comments (0)