Using Express Router as a Viable Alternative to React Router

in react

Not everyone is fond of React Router, but alternative routers are hard to find. In this post I will demonstrate why and how you can use Express router in a React app to build a universal React application with client-side router and server-side rendering.

tl;dr

Using Express for the server side code, already provides us with a router - The Express router. It maps URLs to handler functions in a simple way. It's well tested, well documented and very flexible.

Wes Todd has already wrapped Express Router code allowing it to work in the browser. His project is called nighthawk:
https://github.com/wesleytodd/nighthawk

Roilan has built a nice minimalistic boilerplate for React and Express which provides server side rendering:
https://github.com/Roilan/react-server-boilerplate

In this post I'll explain how to mix the two and create a universal Express/React application which uses express style router. The full source code is available here:
https://github.com/ynonp/react-express-ruoter

Project Structure

The source files are located in src directory in the following structure:

▾ src/
  ▾ app/
    ▾ pages/
        about.js
        home.js
      browser.js
      index.js
      routes.js
    server.js
    template.js

The files server.js and template.js are used solely on the server, browser.js is used solely on the client and all other files are used by both server and client.

I used .js suffix for both normal JavaScript and JSX files. The two React components (JSX files) in the project are located in pages directory, named about.js and home.js.

Defining Routes (Shared Code)

Since we came here to talk about routing, let's start by defining our routes. Here's the contents of the file src/app/routes.js:

import Home from './pages/home';
import About from './pages/about';

export default function addRoutes(router) {
  router.get('/', (req, res, next) => {
    res.locals.component = Home;
    res.locals.initialState = { name: 'ynon' };
    next();
  });

  router.get('/about', (req, res, next) => {
    res.locals.component = About;
    next();
  });
}

As you can see, we're already a long way from React Router in terms of syntax.

Each route is defined using Express syntax as a mapping between url pattern and a handler function. The function itself sets the relevant component and initial data for that component. A server middleware which follows will perform server-side rendering of the selected component when running on the server. In the browser another middleware will call ReactDOM.render to render the selected component.

Because this is express router, we can:

  1. Use req object to read url parameters.
  2. Use url patterns (such as '/products/:id').
  3. Arrange the code in multiple files.
  4. Create conditional routes.

Or use any other feature of Express router that works for you.

Server Side Rendering

The next files are src/server.js and src/template.js. Template defines a function that returns an HTML string for the entire page. The file server.js defines the middleware which performs server side rendering:

addRoutes(server);

server.use(function(req, res, next) {
  const { component, initialState } = res.locals;
  if (!component) {
    return next();
  }

  res.format({
    'text/html': function() {
      const appString = renderToString(React.createElement(component, initialState));
      res.send(template({
        body: appString,
        title: 'Hello World from the server',
        initialState: JSON.stringify(initialState)
      }));
    },

    'application/json': function() {
      res.send(initialState);
    },
  });

  next();
});

The middleware is added after the routes. It takes the data set in res.locals and renders it to an HTML page or a JSON object, depending on the request.

Server Specific Logic

Some routes require server specific behaviour, such as fetching data from a database or reading from files. Express already allows handling the same request by multiple handlers, so all we need to add is another handler before the shared route definitions. The following code from server.js runs only on the server and sets an initial state in res.locals for the about page:

server.get('/about', function(req, res, next) {
  res.locals.initialState = { text: 'I can see the mountains' };
  next();
});

addRoutes(server);

Browser Rendering

Now we have a working node application that serves HTML files created from React JSX code. Next we need to render the same pages when they reach the browser in order to get event handlers working. The file src/app/browser.js Defines the relevant code and is arranged in 3 parts:

Part 1: Creating the router

import React from 'react';
import { render } from 'react-dom';
import Nighthawk from 'nighthawk';
import addRoutes from './routes';

const router = Nighthawk();

addRoutes(router);

On part 1 we call addRoutes to add the same shared route definitions we had on the server. Remember they have no logic in them besides the mapping between url patterns and components. This allows our client side router to know which component to render for each url pattern.

Part 2: Implementing Route Transition Logic as Middleware

router.use(function(req, res, next) {
  fetch(req.url, {
    headers: {
      'Accept': 'application/json',
      'X-Requested-With': 'XMLHttpRequest',    
    }
  }).
    then(function(serverResponse) {
      return serverResponse.json();
    }).
    then(function(data) {
      res.locals.initialState = data;
    }).
    then(next);
});

On part 2 we create a simple middleware to fetch data from server on each route change. This part will get more complex as your program grows, for some route changes won't need to wait for server side data. This is a good place to add caching logic, lazy loading or data pre-fetching and so on. You can even use this middleware for certin url patterns and have other middlewares for other cases. Refer to express documentation for more deatails on middlewares.

Part 3: Render result

router.use(function(req, res, next) {

  render(
    React.createElement(res.locals.component, res.locals.initialState),
    document.getElementById('root'));

  next();
});

Part 3 is performed asynchronous after the middleware in part 2 called next. It calls render using the data in res.locals.

Finally call listen to start the router and handle route transitions:

router.listen();

Nighthawk router automatically attaches an event handler to handle clicks on links, so each <a> element in the application is automatically a router enhanced link. You can have a "normal" anchor by calling Event.stopPropagation such as:

<a href='/about'>About Page - Client Side Routing Enabled</a>
<a href='/blog' onClick={e => e.stopPropagation()}>Blog Page - Normal browser link</a>

Better yet, create a React component to automate the process:

function BrowserLink(props) {
    return <a {...props} onClick={e => e.stopPropagation()} />
}

Layout

One thing I really don't like in React Router is its handling of layouts in the route definitions. It seems like a good idea at first but as the application grows I found I usually need more flexibility. By using the suggested express router we lose the connection between routes and layouts and I think it's for the better.

Instead of having our layout in a route, it's far more robust to define it in the components themselves. Consider the following implementation of a home page component:

function HomePage(props) {
  return (
    <Layout withSidebar={false}>
      <p>Welcome home</p>
    </Layout>
  )
}

How would Layout look like to provide for the above component? As simple as this:

function Layout(props) {
  return (
    <div className='container'>
      <NotificationArea />
      <ModalContainer />
      {props.withSidebar && <Sidebar />}
      {props.children}
      <Footer />
    </div>
  );
}

Now all common layout code is placed in layout.js and component specific code is placed in the components themselves. There's no longer a coupling between component's selected route and its layout.

Final Thoughts

React Router is the industry standard and probably will stay that way for some time. But it's not for everyone and not for every project. Using it just because everybody else does may cost you more than it's worth.

We can get the same level of stability and with much better flexibility from Express router. It's well documented and well tested. Being non-opinionated framework, the Express router lets you build your application as you see fit and gives you the tools to adapt to every scenario.

For my own work the flexibility achived by this structure saved me a lot of development time and wrapper code that was just there to fit in RR way of work.

If you've used this structure, or used Express router for React in a different manner, I'd love to hear how it worked.

Comments