Skip to main content

Command Palette

Search for a command to run...

The Great Routing Lever: File Based or Config

Updated
9 min read
The Great Routing Lever: File Based or Config

The other day I stumbled across a poll from Ryan Toronto getting his followers thoughts on whether they prefer file based routing or config based.

Looking at the options I immediately thought there's clearly an option missing.

Image of Zoidberg with a meme caption of "why not both?"

Let's start with a little of my history

I started programming at the age 12 before I even knew that what I was doing was called programming. My first steps into the web were in PHP. You know, I needed to have that "cool" clan website that me and all my other 12 year old friends could post messages on talking about how "cool" we were playing medal of honor. Thus my introduction to file based routing. It was awesome, easy to understand and to find whatever horribly named php file I had that rendered that music filled flash animation intro page. Again, needed to express how "cool" my clan was.

After high school I got my resume out there will all that PHP experience I had built up. I'd get call after call of interviewers interested in me because they liked my resume, but the moment they found out I just finished high school they all just assumed I wouldn't want to work for them. Which lead me to college and learning things like Java, C# and eventually ASP webforms (which was still very much file based). After getting that degree, I started a job at a design agency.

Now I was fresh into my career and ASP.NET MVC 3 was the new hotness. This was when config based routing started getting injected into my veins. There were default conventions for my controllers and actions but as more and more versions of Microsoft's framework were released, there were more and more config options to change things to my liking.

Now, I'll save you 15 years of personal history of using different web technologies and frameworks like .NET, PHP, Knockout, Durandal, Aurelia, Angular and React; and fast forward to being a Remix supporter.

File based routing was back and with it the rush of nostalgia. The memories of being a teenager working on my silly hobby listening to the sweet rhythm of Suga Suga by Baby Bash. Boy did I miss file based routing. There was something so simple about having your URL match the file system. No longer was I pouring through config files to figure out where that button was that I needed to update.

So which should you choose?

It depends, everyone's favorite answer. I love file based routing because for me, it's easier to wrap my head around my routes and find them. File based routing is great for small to medium sized apps, but often, over time as you add feature after feature, file based routing starts to get messy. Which brings me to one of my favorite things about Remix: levers!

There are tradeoffs for just about everything in life. Some are better than others for the situation. Remix gives you tons of levers to pull and push depending on what you need. That includes the ability to pull that lever from "file based routing" to "config based routing".

There is one thing about levers that often gets overlooked: it's not a toggle button, it's not binary, it's not a simple on and off switch. You can take that lever (or as the roller skating DJ side of me likes to think of it more like a slider) and push it as far as you need to in either direction. Is your project starting to get too big and needs some reorganizing? Do it. Want to keep some file based routing? Do it.

Some of this, some of that, and a little of both

At work we've got a new shiny SaaS product (+4 years in the making). Overtime we've gone from CRA straight to Remix and slowly converting our pages to use the loaders and actions. This product has what our product team calls "modules" which are different feature sets that our customers can enable or disable. Some of these features are larger than others and it was important to make sure these features didn't accidentally bleed into one another.

We created a custom routing configuration for Remix that would more easily allow us to keep these "modules" separated. Here's what a typical remix project's "app" folder often looks like for our company:

/app
    /components
    /helpers
    /routes
    /utils

This is fine for smaller projects, but our project needed some separation. Nothing super special about this, but this is what we ended up with:

/app
    /auth
    /core
    /feature-1
    /feature-2
    /portal
    /shared

Under each of these "module" folders, they each have their own routes, components, helpers and utilities. That way, each "module" can have it's own components that are only related to it's module. Anything that is shared across multiple modules, like a button for example, can be found under the "shared" module. Here's a quick description of what some of these "modules" do that'll hopefully give enough context to how we split things in the code further down:

  • auth - all routes related to handling Azure B2C authentication, signups, invites, etc.

  • core - this is the core module that every customer has. It has settings, users, permissions, navigation, etc.

  • shared - there are no routes here, this is just a shared place to put more generic components, helpers and utilities.

  • feature-X - these are what our product team calls "modules" that differ which are enabled from customer to customer.

  • portal - part of our product is helping our customers gather documents and forms from their suppliers. When our customer needs a document from one of their supplier, they create a document upload task. An email gets sent to that supplier with a specialized URL that allows them to complete the task assigned to them easily without having to log in. This module really should probably be a separate site since authentication and the look and feel are very different, but this was a very early feature.

Alright, let's get to some code. Here's our routes config for Remix:

import { loadModuleRoutes } from './module-routes.js'

/** @type {import('@remix-run/dev').AppConfig} */
var appConfig = {
  routes: async defineRoutes => {
    return loadModuleRoutes(
      appConfig.appDirectory, 
      [
        { 
          path: 'auth' 
        },
        { 
          path: 'portal', 
          prefix: 'portal',
          layout: 'portal/routes/__portal-layout',
        },
        {
          path: 'feature-1',
            prefix: 'feature-1',
          rootLayout: 'core/routes/__global-layout',
          layout: 'feature-1/routes/__feature-1-layout',
        },
        { 
          path: 'core', 
          rootLayout: 'core/routes/__global-layout'
        }
      ],
      defineRoutes,
    )
  },
  // rest of remix config here...
}

export default appConfig

Let's briefly talk about what's going on here. We have a custom module-routes.js file that exports a loadModuleRoutes function. This function takes 3 arguments. First the app directory (aka "app"), second an array of our desired modules, and last is the defineRoutes function provided by Remix. The part that is most interesting is our array of modules, here's the properties:

  • path - this is the folder path to our module that contains file based routing in a routes folder (app/[path]/routes).

  • prefix (optional) - this is if we want to prefix the url with a segment. In the example above for our "portal" module, we prefix with "portal" so all routes in that module are under the URL /portal/*. (You'll note that we have multiple modules that don't have prefixes. Core adds things like /users/* and /settings whereas Auth adds /login, /signup, etc.

  • rootLayout - most of our modules share the same navigation shell. This property points to what file path is our main layout, the piece that adds all of the styling, navigation, etc.

  • layout - some modules need to setup their own layout to populate any providers that they might need. I could have done a pathless layout in the module itself, but we didn't want to clutter the routes folder when we were already halfway configured for it.

🤨
rootLayout and layout are slightly confusing we admit. The only reason why rootLayout needs to exist is because of that special "portal" route that we honestly think should be it's own standalone site since it doesn't share authentication, global navigation, etc. Hopefully we'll find time in the future to make that happen.

Now for the loadModuleRoutes code. A couple of disclaimers: we wrote this code a year or so ago, future Kevin rarely likes past Kevin's styling of coding so I'm sure there are problems. There are better ways to do things, but I rather finish writing this post than clean this guy up. I've added a few more comments in the code to explain some details.

💡
One of our feature modules is getting pretty big, so we're also using remix-flat-routes to take advantage of it's nested folders with flat files convention.
import { flatRoutes } from 'remix-flat-routes'

export function loadModuleRoutes(appDirectory, moduleConfigs, defineRoutes) {
    appDirectory = appDirectory || 'app'
    const routes = {}

    for (const moduleConfig of moduleConfigs) {
        // load module routes from `remix-flat-routes`
        const moduleRoutes = flatRoutes(`${moduleConfig.path}/routes`, defineRoutes, {
            appDir: appDirectory,
        })

        // loop over each route and add parent routes, prefixes, etc.
        for (const key of Object.keys(moduleRoutes)) {
            const route = moduleRoutes[key]

            // if no parentId, default to `root`    
            route.parentId = route.parentId || 'root'

            // if our module has a prefix,            
            // our current route's parent is root,
            // and we aren't the layout
            // add the prefix to the route path 
            if (
                moduleConfig.prefix &&
                route.parentId === 'root' &&
                route.id !== moduleConfig.layout
            ) {
                // if we have path, add the prefix on the front
                if (route.path) {
                    route.path = moduleConfig.prefix + '/' + route.path
                } else {
                    // otherwise we're the index of the module
                    // set our path to the prefix  
                    route.path = moduleConfig.prefix
                }
            }

            // if our module has a layout defined,
            // our parent route is the root and
            // we aren't the layout, set our parent to the layout
            if (
                moduleConfig.layout &&
                route.parentId === 'root' &&
                route.id !== moduleConfig.layout
            ) {
                route.parentId = moduleConfig.layout
            }

            // if our module has a root layout defined,
            // our parent route is the root and
            // we aren't the root layout, set our parent to the root layout
            if (
                moduleConfig.rootLayout &&
                route.parentId === 'root' &&
                route.id !== moduleConfig.rootLayout
            ) {
                route.parentId = moduleConfig.rootLayout
            }
        }

        // merge our module routes to our full routes object
        mergeRoutes(moduleRoutes, routes)
    }

    return routes
}

Hopefully the comments in that file make it clear what we're doing. This is a great way to get the best of both worlds. Being able to have some extra configuration to break things up into feature modules that our product team wants, but still be able to take advantage of file based routing with co-located components.

Pull the levers that help you solve the problem you have.