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.
Structuring the routes
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.
1const routes = { 2 login: "/login", 3 admin: { 4 meetingLinks: { 5 index: "/admin/meeting-links", 6 show: "/admin/meeting-links/:id", 7 design: "/admin/meeting-links/:id/design", 8 new: { 9 index: "/admin/meeting-links/new", 10 what: "/admin/meeting-links/new/what", 11 type: "/admin/meeting-links/new/type", 12 }, 13 }, 14 }, 15};
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.
- Action-specific routes like /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.
Usage of index key
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.
1const routes = { 2 // rest of the routes 3 admin: { 4 availabilities: "/admin/availabilities", 5 // rest of the routes 6 }, 7};
However, if we plan to support multiple actions under the availabilities scope, we will need to use the index key to differentiate between actions.
1const routes = { 2 // rest of the routes 3 admin: { 4 availabilities: { 5 index: "/admin/availabilities", 6 show: "/admin/availabilities/:id", 7 }, 8 // rest of the routes 9 }, 10};
Improving searchability
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.
Avoid nesting for dynamic routes
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:
1const routes = { 2 // rest of the routes 3 admin: { 4 // rest of the routes 5 meetingLinks: { 6 index: "/admin/meeting-links", 7 id: { 8 show: "/admin/meeting-links/:id", 9 design: "/admin/meeting-links/:id/design", 10 members: "/admin/meeting-links/:id/members", 11 }, 12 }, 13 // rest of the routes 14 }, 15};
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:
1const routes = { 2 // rest of the routes 3 admin: { 4 // rest of the routes 5 meetingLinks: { 6 index: "/admin/meeting-links", 7 show: "/admin/meeting-links/:id", 8 design: "/admin/meeting-links/:id/design", 9 members: "/admin/meeting-links/:id/members", 10 }, 11 // rest of the routes 12 }, 13};
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.
File structure
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:
1const routes = { 2 login: "/login", 3 admin: { 4 availabilities: { 5 index: "/admin/availabilities", 6 show: "/admin/availabilities/:id", 7 }, 8 meetingLinks: { 9 index: "/admin/meeting-links", 10 show: "/admin/meeting-links/:id", 11 design: "/admin/meeting-links/:id/design", 12 new: { 13 index: "/admin/meeting-links/new", 14 what: "/admin/meeting-links/new/what", 15 type: "/admin/meeting-links/new/type", 16 }, 17 }, 18 }, 19}; 20 21export default routes;
This approach allows for easy importing and ensures that IntelliSense can auto-complete the fields, enhancing developer productivity.
Using the route keys in the application
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:
1const { 2 admin: { meetingLinks: index }, 3} = routes; 4history.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.
1// Navigate to the meeting links index page 2history.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.
1buildUrl(routes.admin.meetingLinks.design, { id: "123" }); // output: `/admin/meeting-links/123/design` 2buildUrl(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.
Edge cases to consider
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.
Routes starting with a dynamic element
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:
1const routes = { 2 login: "/login", 3 calendar: { 4 show: "/:slug", 5 preBook: { 6 index: "/:slug/pre-book", 7 }, 8 cancellationPolicy: "/:slug/cancellation-policy", 9 troubleshoot: "/:slug/troubleshoot", 10 }, 11 admin: { 12 // Rest of the routes 13 }, 14}; 15 16export default routes;
Here, calendar is the name chosen to group all actions whose paths start with the dynamic element :slug.
Routes ending with consecutive dynamic elements
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:
1const routes = { 2 // Rest of the routes 3 bookings: { 4 views: { 5 show: "/bookings/:bookingId/:view", 6 }, 7 }, 8 admin: { 9 // Rest of the routes 10 }, 11}; 12 13export default routes;
Here, the key routes.bookings.views.show is used. By allowing any meaningful name in place of views, we maintain partial searchability.
Routes with intermittent consecutive dynamic elements
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:
1const routes = { 2 // Rest of the routes 3 bookings: { 4 time: "/bookings/:bookingId/:view/time", 5 }, 6 admin: { 7 // Rest of the routes 8 }, 9}; 10 11export 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.