Automatic routing in Vue.js applications

Automatic routing in Vue.js applications

  • 190

Recently I started working on a new project based on Vue.js and I wanted to do something similar, but for various reasons I didn’t want to use Nuxt. I found the vue-auto-routing package which was exactly what I was looking fo

One of the things that I like about the Nuxt framework is its automatic routing capability. You just have to put all you page components into an appropriate folder structure and everything just works; you don’t have to write the rules for vue-router manually.

Recently I started working on a new project based on Vue.js and I wanted to do something similar, but for various reasons I didn’t want to use Nuxt. I found the vue-auto-routing package which was exactly what I was looking for. It’s essentially a small webpack plugin which automatically generates the routing configuration based on the content a given directory. It has similar capabilities and uses the same rules as Nuxt, so it’s very easy to use.

The whole idea behind automatic routing is that the structure of directories in your source code is analogous to the structure of page URLs. Let’s assume that our application has a module for managing users which uses the following URLs:

  • /users (list of users)
  • /users/add (adding a new user)
  • /users/123 (details of a user)
  • /users/123/edit (editing an existing user)

This could be represented by the following structure in the source code:

pages
|- users
|  |- index.vue
|  |- add.vue
|  |- _id
|  |  |- index.vue
|  |  |- edit.vue

The index.vue, add.vue and edit.vue files are Vue.js components which represent the pages of the application. The underscore in the _id directory name means that it’s a dynamic parameter.

You no longer have to manually write code such as this:

import Router from 'vue-router';
const routes = [
  {
    path: '/users',
    component: () => import( '@/pages/UsersList.vue' )
  },
  {
    path: '/users/add',
    component: () => import( '@/pages/UsersAdd.vue' )
  },
  {
    path: '/users/:id',
    component: () => import( '@/pages/UsersDetails.vue' )
  },
  // ... etc. ...
];
const router = new Router( { routes } );

The entire routes array is generated automatically, so you only need to write this:

import Router from 'vue-router';
import routes from 'vue-auto-routing';
const router = new Router( { routes } );

In a large application, this can save you a lot of work and reduce the chance of making a mistake. The page files become nicely organized and it’s easier to find the file corresponding to a given page simply by looking at its URL.

Shared view components

When I started using this automatic routing approach, I quickly stumbled upon an interesting problem: the pages for adding and editing a user are very similar. In fact, with manual routing, I could map both URLs to the same component, but for automatic routing to work, I need two separate components: add.vue and edit.vue.

The solution is to extract the common part of these two pages into a separate component. In this case it’s called UserForm.vue and it’s placed in a separate folder called “views”. This common component contains the UI of the form and validation logic. The page components, on the other hand, contain code responsible for loading and saving user information (in case of edit.vue) and for adding a user using the API (in case of add.vue), so there is no code duplication.

A big advantage of such approach is that the UI code is separated from the code responsible for communication with the API. Because of that, and to keep consistency, I started using a separate view component even if it’s used only by one page.

This is an example of how the add.vue component can look like:

<template>
  <UserForm @submit="submit"/>
</template>
<script>
export default {
  components: {
    UserForm: () => import( '@/views/UserForm.vue' )
  },
  methods: {
    submit( data ) {
      // logic for adding the user
    }
  }
}

The edit.vue page would be similar, it would just load and pass the inital user properties to the UserForm component.

Parameter validation

As I mentioned before, a directory or file name like _id represents a dynamic parameter in the URL, an equivalent of :id in the route path. Manually written routing rules can include regular expressions to validate these parameters, for example to ensure that the ID only contains digits. This is not possible when using automatic routing.

Nuxt solves this by allowing page components to define a special validate() function which is used to validate route parameters and query string:

export default {
  validate( { params } ) {
    return /^\d+$/.test( params.id );
  }
}

It’s not hard to add similar functionality to our simple application using a global route guard:

router.beforeResolve( ( to, from, next ) => {
  const page = route.matched[ 0 ].components.default;
  const { validate } = page;
  if ( validate != null ) {
    if ( !validate( route ) ) {
      next( new Error( 'Invalid route parameters' ) );
      return;
  }
  next();
} );

The matched property of the route object contains a reference to all matched components; in this example we only check the top level page component and ignore any nested routes, though they are supported by vue-auto-routing.

If the component has a validate function and it returns false, we pass an error to the next() function and the navigation will be aborted.

Page not found

There are a few ways of handling the “page not found” error with vue-router. The first option is to define a catch-all rule which matches any path. We can add such rule to the ones generated by vue-auto-routing:

const router = new Router( { routes: [
   ...routes,
   {
      path: '*',
      component: PageNotFound
   }
] } );

Another option is to use a global route guard. We can extend the one that we already created:

router.beforeResolve( ( to, from, next ) => {
  if ( route.matched.length == 0 ) {
    next( new Error( 'Page not found' ) );
    return;
  }
  const page = route.matched[ 0 ].components.default;
  const { validate } = page;
  if ( validate != null ) {
    if ( !validate( route ) )
      next( new Error( 'Invalid route parameters' ) );
  }
  next();
} );

When the array of matched components is empty, it means that the current URL didn’t match any routing rule, so routing is aborted with an .

Throwing an error is not enough. We must also handle it in order to display an error page. One option is to use a Vuex store:

import Vuex from 'vuex';
Vue.use( Vuex );
const store = new Vuex.Store( {
  state: {
    error: null,
  },
  mutations: {
    setError( state, error ) {
      state.error = error;
    }
  }
} );

The global route guards can clear the error when the route is valid, and store the error in the Vuex store when the router error handler is invoked:

router.beforeResolve( ( to, from, next ) => {
  if ( route.matched.length == 0 ) {
    next( new Error( 'Page not found' ) );
    return;
  }
  const page = route.matched[ 0 ].components.default;
  const { validate } = page;
  if ( validate != null ) {
    if ( !validate( route ) )
      next( new Error( 'Invalid route parameters' ) );
  }
  store.commit( 'setError', null );
  next();
} );
router.onError( error => {
  store.commit( 'setError', error );
} );

The main application component should display the current route only if it’s valid. In there is an error, the error page should be displayed instead:

<template>
  <router-view v-if="error == null">
  <ErrorPage v-else :error="error">
</template>
<script>
import { mapState } from 'vuex';
export default {
  computed: {
    ...mapState( [ 'error' ] )
  }
}
</script>

Note that a routing error can also occur when the page component cannot be loaded, for example because of a network error, so the error page should display the reason of the error. You can read my previous article about error handling in SPA applications to learn how to design error handling to improve user experience.

Global route guards can be used for many other useful things. For example, a busy indicator can be displayed while the page is being loaded by adding a “busy” property to the Vuex store. Another nice improvement is to delay displaying the target page until the shared view component is loaded and all the data which is needed by the page is fetched, to prevent briefly displaying an empty page.

Layouts

Nuxt allows each page to define the layout in which it should be displayed. The layout component contains shared elements, such as the header, footer, navigation menu, etc. The application can have multiple layouts — for example one for the main content, and another one for the administration area.

It’s possible to handle this using a route guard as well, by passing the name of the layout to the global store and modifying the application component so that it displays the layout component dynamically.

However, I like another approach inspired by a great article: Advanced Vue: Controlling Parent Slots (Case Study) by Michael Thiessen. One of the suggested solutions is that instead of putting the page content inside a layout, we can reverse the logic and put the layout into the page. For example:

<template>
  <PageLayout title="Add User">
    <UserForm @submit="submit"/>
  </PageLayout>
</template>
<script>
export default {
  components: {
    UserForm: () => import( '@/views/UserForm.vue' )
  },
  methods: {
    submit( data ) {
      // logic for adding the user
    }
  }
}

The PageLayout component is just a regular Vue component with a slot for the page content. It can be as simple as this:

<template>
  <h1>{{ title }}</h1>
  <slot/>
</template>
<script>
export default {
  props: {
    title: String
  }
}
</script>

The advantage of such approach is that the page has full control over the layout component. It can pass properties to that component, such as the title in the example above, and react to events. The layout can also define additional named slots, for example for a side panel whose content can be populated with different content depending on the current page.

Final notes

At this point you might be wondering: why not just use Nuxt? Of course in a lot of cases this is the best solution. Nuxt is a great framework for a wide area of applications and it can greatly simplify development. However, all frameworks force you to do things in a certain way. This can be both good and bad. If you prefer to do some things in a different way, or have more control and flexibility, a custom solution might be a better option.

Automatic routing is one of the most useful features of Nuxt, and you can take advantage of it even if you prefer to build your application without using a framework.