DEV Community

Ernesto Bellei for hund

Posted on • Updated on

Dominate routing in Express

When a project starts growing in complexity, you might end up with different routes for API handling, page-serving, webhooks etc...

To avoid your index.ts to grow indefinitely one smart approach would be to divide all the different routes into groups (ae. by path, functionality, auth type etc...) and load them into your file. It can be done using different approaches to fit different levels of complexity.

I'm not a fan of overcomplicating the project with sophisticated dynamic loading if not strictly required.

Step 1: Create groups of routes

Let's say you want to group all API routes under the same /api URL path. The easiest solution would be to create a new express.Router;

const apiRouter = express.Router();
Enter fullscreen mode Exit fullscreen mode

Append all the needed POST and GET routes to the newly created apiRouter...

apiRouter.get("/hello", async (request, response, next) => {
    try {
        return response.status(200).json({ foo: "bar" });
    } catch (e) {
        const error = createHttpError(500);
        return response.status(error.statusCode).json(error);
    }
});

apiRouter.get("/world", async (request, response, next) => {
    try {
        return response.status(200).json({ hello: "world" });
    } catch (e) {
        const error = createHttpError(500);
        return response.status(error.statusCode).json(error);
    }
});
Enter fullscreen mode Exit fullscreen mode

...and finally, add your apiRouter to the express app

app.use("/api", apiRouter);
Enter fullscreen mode Exit fullscreen mode

In my opinion, if your app has two or three /api routes grouping them using this system would be enough.
This configuration will allow you to apply specific middleware and custom rules to your /api routes with ease.

app.use("/api", apiRouter, passwordAuthenticationMiddleware);
Enter fullscreen mode Exit fullscreen mode

Step 2: Store groups of routes inside different files

By now we grouped all the different routes but everything it is still inside the main index.ts file. As stated before, this is ok for a rather simple API with two or three small routes. As soon as the project grows in size and complexity grouping routes together wouldn't be enough and moving those routes to a different file will be necessary.

I would probably use a file structure similar to this one:

.
|--routes
|  |--api
|  |  |--hello
|  |  |  |--index.ts
|  |  |  |--get.ts
|  |  |--world
|  |  |  |--index.ts
|  |  |  |--get.ts
|  |  |  `--post.ts
|  |  `--index.ts
|  `--index.ts
|--public
`--index.ts
Enter fullscreen mode Exit fullscreen mode

First of all, we have to 'pack' everything inside a ./routes folder and then import using a structure which mimics the routes paths structure:

GET  /api/hello

GET  /api/world
POST /api/world
Enter fullscreen mode Exit fullscreen mode

Starting from the 'deeper' file which identifies the request type (GET, POST, PUT, PATCH...)and will contain the actual response return:

// 📄 ./routes/api/hello/get.ts
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";

const get = async (
    request: Request,
    response: Response,
    next: NextFunction
) => {
    try {
        return response.status(200).json({ foo: "bar" });
    } catch (e) {
        const error = createHttpError(500);
        return response.status(error.statusCode).json(error);
    }
};

export { get };
Enter fullscreen mode Exit fullscreen mode
// 📄 ./routes/api/hello/index.ts
export { get } from "./get";
Enter fullscreen mode Exit fullscreen mode

It is long and boring but always tries to create an index.ts file to export your function (you can eventually use a vsCode plugin).

Going up to ./routes/api we will need to assemble all our API routes using an /api router...

// 📄 ./routes/api/index.ts
import express from "express";
import { get as helloGet } from "./hello";
import { get as worldGet } from "./world";
import { post as worldPost } from "./world";

const router = express.Router();

router.get("hello", helloGet);
router.get("world", worldGet);
router.post("world", worldPost);

export { router };
Enter fullscreen mode Exit fullscreen mode
// 📄 ./routes/index.ts
export { router as apiRouter } from "./api";
Enter fullscreen mode Exit fullscreen mode

...and we are now ready to consume it...

// 📄 ./index.ts
import { apiRouter } from "./routes";

/* ...some in between code here... */

app.use("/api", apiRouter);

/* ...some in between code here... */
Enter fullscreen mode Exit fullscreen mode

Now things are getting quite complex... but hey! your index.ts file is much clearer now and going through it in the distant future will be painless than using a single huge file.

I'm not a fan of overcomplicating the project with sophisticated dynamic loading if not strictly required.

Even though it might seem a huge Matrioska of code at some point it will be inevitable (unless you are planning to use some different framework, which is a smart choice too).

This brings us straight to...

Step 3: Create an automation to load all those routes automatically

Now. I know that many of you directly jump to this point because usually, the last point is the one which assembles everything in a working piece of code...BUT

I'm not a fan of overcomplicating the project with sophisticated dynamic loading if not strictly required.

For this reason, unless you are crying in pain because adding new routes has become impossible yet... you should give it a shot at Step 1 or Step 2 and check out if it is not worth stopping at that stage.

That said. Let's move forward.

⚠️ This solution might not fit your case: it has been written assuming that you are working on a plain and basic express application bundled with webpack >5.

Starting from here...

app.use("/api", apiRouter);
Enter fullscreen mode Exit fullscreen mode

...our goal is to achieve something like that:

await loadRoutes(path.join(__dirname, "routes"), app);
Enter fullscreen mode Exit fullscreen mode

A function that, given one entry path, load all our routes on our app.

First and foremost we need to define two functions which will allow us to get files and folders in a specific path, something like this perhaps:

const getSubfolders = (path: string) =>
    fs
        .readdirSync(path, { withFileTypes: true })
        .filter((file) => file.isDirectory());
Enter fullscreen mode Exit fullscreen mode
const getFiles = (path: string) =>
    fs
        .readdirSync(path, { withFileTypes: true })
        .filter((file) => !file.isDirectory());
Enter fullscreen mode Exit fullscreen mode

To start scraping recursively from top to bottom we need to wrap everything in a function which will lately call itself, starting from the routesRoot folder and will append all the found middlewares to the generated routes. Let's do it one step at a time.

const loadRoutes = async (routesRoot: string, router: Router) => {};
Enter fullscreen mode Exit fullscreen mode

Inside our loadRoutes folder we will start looking for subfolders (we are not interested in files here since we are inside the root and we do not expect to find any middleware here).

To import all routers and middlewares from the routes tree we will use a dynamic import, this way webpack will be aware of what kind of chunks has to bundle separately.

Since dynamic imports are async functions we need to use a lot of await and Promise.all().

Let's start scraping our root folder and create relative paths which we will use inside dynamic imports (disclaimer: this relative path is requested to use dynamic imports. It won't work with absolute paths):

const loadRoutes = async (routesRoot: string, router: Router) => {
    await Promise.all(
        getSubfolders(routesRoot).map(async ({ name }) => {
            const subFolder = `${routesRoot}/${name}`;
            const relativePath = path.relative(__dirname, subFolder);
        })
    );
};
Enter fullscreen mode Exit fullscreen mode

Given this structure:

.
|--routes
|  |--api
|  |  |--hello
|  |     `-get.ts
|  `--welcome
|     |--index.ts
|     |--get.ts
|     `--post.ts
|--public
`--index.ts
Enter fullscreen mode Exit fullscreen mode

We are now mapping api and welcome folders and their relative path will be routes/api and routes/welcome.

Next step is to create/import an express.Router() our rule will be:

  • If the subfolder contains an index.ts file we will try to export an express.Router() out of it;
  • If the index.ts file does not exist we will create a brand new router to handle the routes;

We can do something like this:

const loadRoutes = async (routesRoot: string, router: Router) => {
    await Promise.all(
        getSubfolders(routesRoot).map(async ({ name }) => {
            const subFolder = `${routesRoot}/${name}`;
            const relativePath = path.relative(__dirname, subFolder);

            let subRouter: Router;

            try {
                const { default: customRouter } = await import(
                    `./${relativePath}/index.ts`
                );
                subRouter = customRouter;
            } catch (e) {
                subRouter = express.Router();
            }

            router.use(`/${name}`, subRouter);
        })
    );
};
Enter fullscreen mode Exit fullscreen mode

You might have noticed that our loadRoutes accepts two parameters:

  • routesRoot: is the path of our routes root folder form which we will start importing files and folders;
  • router: is the initial router that we want to attach our routes (ad example the app router created with the express app).

Ok, let's recap a bit. Now we are scraping each subfolder and creating a sub router which will handle all routes contained in the such folder;

routers:
app.use(apiRouter)
  `--apiRouter.use(helloRouter)

app.use(welcomeRouter)
Enter fullscreen mode Exit fullscreen mode

Now we must implement our scraping folder to look not only for subfolders and index.ts files but for get, post, put etc... files

Something like this:

type ExpressRouterRequestMethod =
    | "all"
    | "get"
    | "post"
    | "put"
    | "delete"
    | "patch";

const loadRoutes = async (routesRoot: string, router: Router) => {
    const expressRouterRequestMethod = [
        "all",
        "get",
        "post",
        "put",
        "delete",
        "patch",
    ];

    await Promise.all(
        getSubfolders(routesRoot).map(async ({ name }) => {
            const subFolder = `${routesRoot}/${name}`;
            const relativePath = path.relative(__dirname, subFolder);

            let subRouter: Router;

            try {
                const { default: customRouter } = await import(
                    `./${relativePath}/index.ts`
                );
                subRouter = customRouter;
            } catch (e) {
                subRouter = express.Router();
            }

            await Promise.all(
                getFiles(subFolder).map(async ({ name }) => {
                    const requestMethodName = name.replace(".ts", "");

                    try {
                        if (expressRouterRequestMethod.includes(requestMethodName)) {
                            const { default: customMiddleware } = await import(
                                `./${relativePath}/${requestMethodName}.ts`
                            );

                            subRouter[requestMethodName as ExpressRouterRequestMethod](
                                "/",
                                customMiddleware
                            );
                        }
                    } catch (e) {
                        console.log(e);
                    }
                })
            );

            await loadRoutes(path.join(__dirname, subFolder), subRouter);

            router.use(`/${name}`, subRouter);
        })
    );
};
Enter fullscreen mode Exit fullscreen mode

As you can see, at the time of writing, I Had to manually specify the accepted request method and check them manually otherwise typescript will complain a lot. This is ugly and dirty so comment/writes to me if you find a more elegant way. Check out the express docs here.

This way we are basically importing every valid router request type and attaching it to the right router in order to have a structure like this one:

routers:
app.use(apiRouter)
  `--apiRouter.use(helloRouter)
    `--helloRouter.get(getHelloMiddleware)

app.use(welcomeRouter)
  |--welcomeRouter.get(getWelcomeMiddleware)
  `--welcomeRouter.get(postWelcomeMiddleware)
Enter fullscreen mode Exit fullscreen mode

The last step is to export everything in the right format. In step 2 we grouped everything using routers to apply middlewares to multiple routes. We can keep that structure by reworking it a bit to simply our dynamic import

// 📄 ./routes/api/hello/get.ts
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";

- const get = async (
+ const middleware = async(
    request: Request,
    response: Response,
    next: NextFunction
) => {
    try {
        return response.status(200).json({ foo: "bar" });
    } catch (e) {
        const error = createHttpError(500);
        return response.status(error.statusCode).json(error);
    }
};

- export { get };
+ export default middleware;
Enter fullscreen mode Exit fullscreen mode
// 📄 ./routes/api/index.ts
import express from "express";
- import { get as helloGet } from "./hello";
- import { get as worldGet } from "./world";
- import { post as worldPost } from "./world";

const router = express.Router();

+ router.use(authMiddleware); // this is useful to apply a middleware to all subroutes

- router.get("hello", helloGet);
- router.get("world", worldGet);
- router.post("world", worldPost);

- export { router };
+ export default router; // export default instead of named export
Enter fullscreen mode Exit fullscreen mode

The next step will probably create a separate file and import this function from there or whatever you like the most.

This is my five cents about express routing handling on small applications.
The entire article could be resumed as follows:

  • Don't overcomplicate your application ahead of time, with 2 routes you don't need anything overcomplicated as it will cause stupid problems during the prototyping phase;
  • Beware of copy/pasting because once you start adding ts + webpack + custom loaders + whatever you will have to adjust your setup accordingly.

Stupid problems I faced while writing this

  • To read, write and scan files and directories freely on webpack remember to enable this option inside your webpack.config.js;
node: {
            __dirname: true,
        }
Enter fullscreen mode Exit fullscreen mode
  • When using dynamic import webpack automatically manage the bundle without specifying any particular entry option to create custom output bundles;
  • When using dynamic imports try to keep as much as possible plain string paths. That's why I'm using ./${relativePath}/${requestMethodName}.ts instead of ${name}, when using variables webpack will try to match all possible escaped paths warning you for each not existing file.

That's it, I guess. Bye 👋

Top comments (0)