September 24, 2024
We often benefit from the ability to easily identify which component is rendered by simply examining the application UI. By consistently defining routes and mapping them to components, we can easily locate the rendered component by searching for the corresponding route. This practice also helps us understand the component's behavior, including when it is rendered and the events leading up to it.
This blog post explores a standardized approach to defining frontend routes. The goal is to enhance the searchability of components based on the URL structure. Neeto has adopted a structured and hierarchical approach to defining frontend routes, prioritizing navigational clarity and ensuring consistency and scalability throughout its application ecosystem. Let's have a closer look at this structure.
The philosophy behind route structure is to create a clear, hierarchical, and organized way of defining routes for a web application. Let's understand how, at Neeto, we follow this philosophy with an example. Given below is the route definition of a meeting scheduling application like NeetoCal.
const routes = {
login: "/login",
admin: {
meetingLinks: {
index: "/admin/meeting-links",
show: "/admin/meeting-links/:id",
design: "/admin/meeting-links/:id/design",
new: {
index: "/admin/meeting-links/new",
what: "/admin/meeting-links/new/what",
type: "/admin/meeting-links/new/type",
},
},
},
};
The routes here are organized hierarchically to reflect the logical structure of
the application. Each nested level represents a deeper level of specificity or
functionality. For instance, under the admin
route, there are further nested
routes for meetingLinks
, and within meetingLinks
, there are routes for
specific actions like index
and show
. This indicates that the admin panel of
the application includes provisions for listing meeting links and showing
details of individual meeting links.
These routes also follow RESTful principles, whenever possible, by using descriptive and meaningful path names. The paths indicate the resource being accessed and the action being performed. For example:
index
routes like /admin/meeting-links
is for listing resources.show
routes like /admin/meeting-links/:id
is for viewing a specific
resource./admin/meeting-links/:id/design
is for
performing actions on a specific resource.By defining routes in a nested object structure, it becomes clear how routes are
related. This improves readability and maintainability. The nested structure
allows for easy scalability as well. New routes can be added in a logical place
within the hierarchy without disrupting the existing structure. For example, if
a new action needs to be added to meeting-links, it can be easily included under
the appropriate new
subroute.
String interpolation should be strictly avoided in the path values. Otherwise they can lead to inconsistencies in route definitions and make searching difficult.
At Neeto, we have an ESLint rule routes-should-match-object-path
in
@bigbinary/eslint-plugin-neeto
which ensures that the path value matches the
key. Let's take a few examples to discuss this ESLint rule.
In the above case we have the key routes.admin.meetingLinks.index
. The path
for that key is /admin/meeting-links
. What if I change the path value from
/admin/meeting-links
to /admin/meeting-urls
. If we do that then ESLint will
throw an error because now the key will not match with the path.
Since the key has the value meetingLinks
the path can be either
meeting-links
or meeting_links
. But the path can't be meetinglinks
.
Because then L
will not be camelcased on the key side and that will throw and
error by ESLint.
Similarly if we have the key routs.admin.meetingLinks.video
then the path must
be /admin/meeting-links/video
.
Imagine we're enhancing our application by introducing a feature that lists all
available time slots for scheduling meetings with a person. This scenario
requires an index
action. However, if listing is the sole action within the
availabilities
context, there's no need to explicitly use the index
key.
Instead, we can directly use availabilities
as the key for the path.
const routes = {
// rest of the routes
admin: {
availabilities: "/admin/availabilities",
// rest of the routes
},
};
However, if we plan to support multiple actions under the availabilities
scope, we will need to use the index
key to differentiate between actions.
const routes = {
// rest of the routes
admin: {
availabilities: {
index: "/admin/availabilities",
show: "/admin/availabilities/:id",
},
// rest of the routes
},
};
The structured route definitions can significantly enhance the ease of searching for specific route keys. Let's see how this works in practice.
Assume you are on the /admin/meeting-links
page of the application, as
indicated by the address bar in the browser. To determine the component
associated with this route follow these steps:
Generate the key by replacing all forward slashes with periods and convert the
path to camelCase. We adhere to camelCase for all path keys to ensure
consistency. Thus, /admin/meeting-links
becomes admin.meetingLinks
.
By examining the page associated with /admin/meeting-links
, we can determine
if multiple actions exist under the meeting-links
scope. If multiple actions
exists, then append .index
to the key. If listing is the only action,
admin.meetingLinks
will suffice. Let's say there are multiple actions like
showing details or editing, under the meeting-links
scope. So we should use
admin.meetingLinks.index
as the key for searching.
Use this formatted key to search in your preferred code editor. This search should help you locate the relevant route definitions and associated components.
When dealing with dynamic elements like :id
in route paths, avoiding nesting
can enhance searchability and maintain consistency across different parts of the
application.
Consider a scenario where we need to manage various aspects of meeting links in
an admin panel. Each meeting link has a unique identifier :id
, and we want to
create routes for actions like viewing details, designing, and managing members
of these meeting links. Initially, we might be tempted to nest these actions
under id
as shown below:
const routes = {
// rest of the routes
admin: {
// rest of the routes
meetingLinks: {
index: "/admin/meeting-links",
id: {
show: "/admin/meeting-links/:id",
design: "/admin/meeting-links/:id/design",
members: "/admin/meeting-links/:id/members",
},
},
// rest of the routes
},
};
This structure appears logical but has a critical flaw. The goal of structured
routing is to enhance searchability. In this scenario, if a developer sees a
path like /admin/meeting-links/9482af15-9443-42d1-9b3d-61daeadf6982/design
in
the browser's address bar, they might search for
routes.admin.meetingLinks.meetingId.design
or
routes.admin.meetingLinks.mId.design
to find the associated component.
However, neither of these searches would yield relevant results because the
actual key is routes.admin.meetingLinks.id.design
. This confusion arises
because we allowed for assumptions about the key used for the dynamic part of
the route.
By avoiding the use of dynamic elements in nested object path, we can prevent this issue. Here's how the corrected nesting should look:
const routes = {
// rest of the routes
admin: {
// rest of the routes
meetingLinks: {
index: "/admin/meeting-links",
show: "/admin/meeting-links/:id",
design: "/admin/meeting-links/:id/design",
members: "/admin/meeting-links/:id/members",
},
// rest of the routes
},
};
This approach ensures that the routes are structured logically and predictably.
Now the developer won't face any confusion since the key
routes.admin.meetingLinks.design
will not have any dynamic elements in it.
To maintain consistency and organization, route definitions should be placed in
a centralized file, src/routes.js
. The routes should be defined as a constant
and exported as the default export like given below:
const routes = {
login: "/login",
admin: {
availabilities: {
index: "/admin/availabilities",
show: "/admin/availabilities/:id",
},
meetingLinks: {
index: "/admin/meeting-links",
show: "/admin/meeting-links/:id",
design: "/admin/meeting-links/:id/design",
new: {
index: "/admin/meeting-links/new",
what: "/admin/meeting-links/new/what",
type: "/admin/meeting-links/new/type",
},
},
},
};
export default routes;
This approach allows for easy importing and ensures that IntelliSense can auto-complete the fields, enhancing developer productivity.
Usage of the routes within the application is as equally important as defining them to catalyze searchability. Let's take a look at some of the concepts to consider while using routes keys in the application.
Firstly, do not destructure keys in the route object when you utilize them in various parts of the application like below:
const {
admin: { meetingLinks: index },
} = routes;
history.push(index);
It can hamper searchability. Maintain the complete route path as a single key to ensure clarity and ease of searching.
Secondly, during in-page navigation, we must use the route keys instead of hardcoded strings. This practice not only enhances searchability but also minimizes the risk of errors due to typos or incorrect paths.
// Navigate to the meeting links index page
history.push(routes.admin.meetingLinks.index);
When dealing with dynamic parameters in URLs, we can make use of the buildUrl
function from @bigbinary/neeto-commons-frontend
.
@bigbinary/neeto-commons-frontend
is a library that packages common
boilerplate frontend code necessary for all Neeto products. The buildUrl
function builds a URL by inflating a route-like template string, say
/admin/meeting-links/:id/design
, using the provided parameters. It allows you
to create URLs dynamically based on a template and replace placeholders with
actual values. Any additional properties in the parameters will be transformed
to snake case and attached as query parameters to the URL.
buildUrl(routes.admin.meetingLinks.design, { id: "123" }); // output: `/admin/meeting-links/123/design`
buildUrl(routes.admin.meetingLinks.design, { id: "123", search: "abc" }); // output: `/admin/meeting-links/123/design?search=abc`
The @bigbinary/eslint-plugin-neeto
used within the Neeto ecosystem, features a
rule called use-common-routes
that disallows the usage of strings and template
literals in the path prop of Route
component and in the to
prop of Link
,
NavLink
, and Redirect
components. It also prevents the usage of strings and
template literals in history.push()
and history.replace()
methods.
Even with a structured approach, you may encounter scenarios where adhering to the guidelines is challenging. Let's explore some of these scenarios and how to ensure minimal searchability in such cases.
We have discussed omitting intermittent dynamic contents in paths. However, when there are actions with paths beginning with a dynamic element, we can group them under a meaningful name. While this might hinder searchability, it allows the code editor to partially match the routes. Consider the below case:
const routes = {
login: "/login",
calendar: {
show: "/:slug",
preBook: {
index: "/:slug/pre-book",
},
cancellationPolicy: "/:slug/cancellation-policy",
troubleshoot: "/:slug/troubleshoot",
},
admin: {
// Rest of the routes
},
};
export default routes;
Here, calendar
is the name chosen to group all actions whose paths start with
the dynamic element :slug
.
Consider the path /bookings/:bookingId/:view
. Using routes.bookings.show
can
cause confusion and omit important information about the dynamic element
:view
. In such cases, we can use a meaningful name to group the last dynamic
element. Here is how the object would look:
const routes = {
// Rest of the routes
bookings: {
views: {
show: "/bookings/:bookingId/:view",
},
},
admin: {
// Rest of the routes
},
};
export default routes;
Here, the key routes.bookings.views.show
is used. By allowing any meaningful
name in place of views
, we maintain partial searchability.
When paths contain consecutive dynamic elements, such as
/bookings/:bookingId/:view/time
, we can omit the dynamic elements directly.
Here is how the route would look:
const routes = {
// Rest of the routes
bookings: {
time: "/bookings/:bookingId/:view/time",
},
admin: {
// Rest of the routes
},
};
export default routes;
With that we come to the end of the discussion on structuring frontend routes.
Standardizing frontend routes and dynamic URL generation improves searchability,
maintainability, and scalability. By following a structured, hierarchical
approach and utilizing tools like the buildUrl
function, developers can
efficiently manage and navigate the application's routing system.
If this blog was helpful, check out our full blog archive.