🔥 Get 650+ Tech Books and Courses for $3/m for 3 months

A Beginner’s Guide to Vue Router

    Wern Ancheta
    Share

    In this tutorial, we’ll be looking at how we can implement routing in a Vue app using Vue Router. So we can have a hands-on practice, we’ll be building a simple Pokedex app using Vue and Vue Router.

    Specifically, we’ll be covering the following:

    • setting up a router
    • route parameters
    • declarative and programmatic navigation
    • nested routes
    • 404 pages

    Every JavaScript UI framework that allows the creation of single page applications needs a way to navigate users from one page to another. All of this needs to be managed on the client-side by syncing the view that’s currently displayed on the page with the URL in the address bar. In the Vue world, the [official library] for managing this type of task is Vue Router.

    As ever, the code for this tutorial can be found on GitHub.

    Prerequisites

    The following are required so you can make the best use of this tutorial:

    • Basic knowledge of HTML, CSS, JavaScript, and Vue. If you know how to render something on the page using Vue, you should be able to follow along. A little knowledge on APIs would also help.
    • Node.js and Vue CLI installed on your machine. We’ll be using Vue 3 in this tutorial so make sure Vue CLI is updated.

    App Overview

    We’re going to build a Pokedex app. It will have three pages:

    • Pokemon list page. This is the default page which lists all the original 151 Pokemon.

      Pokemon List

    • Pokemon page. This is where we display the basic details such as type and the description.

      Pokemon Page

    • Pokemon details page. This is where we display the evolution chain, abilities and moves.

      Pokemon details

    Setting Up the App

    Spin up a new Vue app using the Vue CLI:

    vue create poke-vue-router
    

    Choose Vue 3 from the options listed:

    Vue CLI options

    Once it’s done, navigate inside the project folder and install the libraries we need:

    cd poke-vue-router
    npm install vue-router@4 axios
    

    Note that we’re using Vue Router 4 instead of 3, which is the default result that shows up when you Google it. It’s at next.router.vuejs.org as opposed to router.vuejs.org. We’re using Axios to make a request to PokeAPI v2.

    At this point, it’s a good idea to run the project to make sure the default Vue app is working:

    npm run serve
    

    Visit http://localhost:8080/ on your browser and check to see if the default Vue app is running. It should show something like this:

    default vue app

    Next, you need to add sass-loader as a dev dependency. For the purpose of this tutorial, it’s best to just install the same version I used. This is because, at the time of writing, the latest version isn’t compatible with Vue 3:

    npm install sass-loader@10.1.1 --save-dev
    

    You also need to install node-sass, for the same reason as above. It’s best to stick with the same version as mine:

    npm install node-sass@4.14.1 --save
    

    Note: if installing Sass this way doesn’t work for you, you can also choose Manually select features when creating the Vue app with the CLI. Then, select CSS Preprocessors and pick Sass/SCSS (with dart-sass).

    Creating the App

    Now we’re ready to start building the app. As you follow along, remember that the root directory is the src folder.

    Start by updating the main.js file. This is where we import the root component App.vue and the router/index.js file where we declare all things related to routing:

    // main.js
    
    import { createApp } from "vue";
    import App from "./App.vue";
    import router from "./router";
    
    const app = createApp(App);
    app.use(router);
    app.mount("#app");
    

    Setting up a router

    In the App.vue file, use the router-view component provided by Vue Router. This is the top-most component used by Vue Router that renders the corresponding component for the current path visited by the user:

    // App.vue
    <template>
      <div id="app">
        <router-view />
      </div>
    </template>
    
    <script>
    export default {
      name: "App",
    };
    </script>
    

    Next, create a new router/index.js file and add the following. To create a router, we need to extract createRouter and createWebHistory from Vue Router. createRouter allows us to create a new router instance, while createWebHistory creates an HTML5 history that’s basically a wrapper for the History API. It allows Vue Router to manipulate the address in the address bar when we’re navigating between pages:

    // router/index.js
    import { createRouter, createWebHistory } from "vue-router";
    

    Below that, import all the pages we’ll be using:

    import PokemonList from "../views/PokemonList.vue";
    

    Vue Router requires an array of objects containing the path, name, and component as its properties:

    • path: this is the pattern you’d like to match. In the code below, we’re matching for the root path. So if the user tries to access http://localhost:8000, this pattern is matched.
    • name: the name of the page. This is the unique identifier for the page and is what’s being used when you want to navigate to this page from other pages.
    • component: the component you want to render when the path matches the URL the user accessed.
    const routes = [
      {
        path: "/",
        name: "PokemonList",
        component: PokemonList,
      },
    ];
    

    Finally, create the router instance by supplying an object containing the history and the routes to createRouter:

    const router = createRouter({
      history: createWebHistory(),
      routes,
    });
    
    export default router;
    

    That’s all we need for now. You might be wondering where the other pages are. We’ll add them later as we go along. For now, let’s work on the default page first.

    Creating a page

    Creating a page doesn’t really need any special code. So if you know how to create a custom component in Vue, you should be able to create a page for Vue Router to use.

    Create a views/PokemonList.vue file and add the code below. In this file, we’re using a custom List component to render the Pokemon list. The only thing we really need to do is to supply the data for the List component to use. We make a request to PokeAPI once the component is mounted. We don’t want the list to get too big, so we’re limiting the results to the original 151 Pokemon. Once we get the results back, we simply assign it to the component’s items data. This will in turn update the List component:

    <template>
      <List :items="items" />
    </template>
    
    <script>
    import axios from "axios";
    import List from "../components/List.vue";
    
    export default {
      name: "PokemonList",
      data() {
        return {
          items: null,
        };
      },
      mounted() {
        axios.get(`https://pokeapi.co/api/v2/pokemon?limit=151`).then((res) => {
          if (res.data && res.data.results) {
            this.items = res.data.results;
          }
        });
      },
      components: {
        List,
      },
    };
    </script>
    

    Here’s the code for the List component. Components are stored in the components directory, so create a components/List.vue file and add the following:

    <template>
      <div v-if="items">
        <router-link
          :to="{ name: 'Pokemon', params: { name: row.name } }"
          class="link"
          v-for="row in items"
          :key="row.name"
        >
          <div class="list-item">
            {{ row.name }}
          </div>
        </router-link>
      </div>
    </template>
    
    <script>
    export default {
      name: "List",
      props: {
        items: {
          type: Array,
        },
      },
    };
    </script>
    
    <style lang="scss" scoped>
    @import "../styles/list.scss";
    </style>
    

    You can check out the code for the styles/list.scss file in the GitHub repo.

    At this point, you can now view the changes in the browser. Except you get the following error instead:

    Vue error

    This is because Vue is trying to generate the link to the Pokemon page but there isn’t one yet. The Vue CLI is smart enough to warn you of that. You can temporarily solve this issue by using a <div> instead for the template of components/List.vue file:

    <template>
      <div v-if="items">
        <div v-for="row in items" :key="row.name">{{ row.name }}</div>
      </div>
    </template>
    

    With that, you should be able to see the list of Pokemon. Remember to change this back later once we add the Pokemon page.

    Declarative Navigation

    With Vue Router, you can navigate in two ways: declaratively, and programmatically. Declarative navigation is pretty much the same as what we do with the anchor tag in HTML. You just declare where you want the link to navigate to. On the other hand, programmatic navigation is done by explicitly calling Vue Router to navigate to a specific page when a user action is performed (such as a button button being clicked).

    Let’s quickly break down how this works. To navigate, you need to use the router-link component. The only property this requires is :to. This is an object containing the name of the page you want to navigate to, and an optional params object for specifying the parameters you want to pass to the page. In this case, we’re passing in the name of the Pokemon:

    <router-link
      :to="{ name: 'Pokemon', params: { name: row.name } }"
      class="link"
      v-for="row in items"
      :key="row.name"
    >
      <div class="list-item">
        {{ row.name }}
      </div>
    </router-link>
    

    To visualize how this works, you need to know the pattern used by the Pokemon screen. Here’s what it looks like: /pokemon/:name. :name represents the param name that you passed in. For example, if the user wanted to view Pikachu, the URL would look like http://localhost:8000/pokemon/pikachu. We’ll get back to this in more detail shortly.

    Route parameters

    We’ve already seen how we can match specific patterns for our routes, but we haven’t gone through how we can pass in custom parameters yet. We’ve seen it briefly through the router-link example earlier.

    We’ll use the next page (Pokemon) to illustrate how route parameters work in Vue Router. To do that, all you need to do is prefix the name of the parameter with colon (:). In the example below, we want to pass in the name of the Pokemon, so we added :name. This means that if we want to navigate to this specific route, we need to pass in a value for this parameter. As we’ve seen in the router-link example earlier, this is where we pass the name of the Pokemon:

    // router/index.js
    import PokemonList from "../views/PokemonList.vue";
    
    import Pokemon from "../views/Pokemon"; // add this
    
    const routes = [
      {
        path: "/",
        name: "PokemonList",
        component: PokemonList,
      },
      // add this:
      {
        path: "/pokemon/:name",
        name: "Pokemon",
        component: Pokemon,
      }
    ]
    

    Here’s the code for the Pokemon page (views/Pokemon.vue). Just like the PokemonList page earlier, we’re delegating the task of rendering the UI to a separate component BasicDetails. When the component is mounted, we make a request to the API’s /pokemon endpoint. To get the Pokemon name passed in as a route parameter, we use this.$route.params.name. The property we’re accessing should be the same as the name you gave to the parameter in the router/index.js file. In this case, it’s name. If you used /pokemon/:pokemon_name for the path instead, you access it with this.$route.params.pokemon_name:

    <template>
      <BasicDetails :pokemon="pokemon" />
    </template>
    <script>
    import axios from "axios";
    import BasicDetails from "../components/BasicDetails.vue";
    
    export default {
      name: "Pokemon",
      data() {
        return {
          pokemon: null,
        };
      },
      mounted() {
        const pokemon_name = this.$route.params.name;
    
        axios
          .get(`https://pokeapi.co/api/v2/pokemon/${pokemon_name}`)
          .then((res) => {
            const data = res.data;
    
            axios
              .get(`https://pokeapi.co/api/v2/pokemon-species/${pokemon_name}`)
              .then((res) => {
                Object.assign(data, {
                  description: res.data.flavor_text_entries[0].flavor_text,
                  specie_id: res.data.evolution_chain.url.split("/")[6],
                });
    
                this.pokemon = data;
              });
          });
      },
      components: {
        BasicDetails,
      },
    };
    </script>
    

    Here’s the code for the BasicDetails component (components/BasicDetails.vue):

    <template>
      <div v-if="pokemon">
        <img :src="pokemon.sprites.front_default" :alt="pokemon.name" />
        <h1>{{ pokemon.name }}</h1>
        <div class="types">
          <div
            class="type-box"
            v-for="row in pokemon.types"
            :key="row.slot"
            v-bind:class="row.type.name.toLowerCase()"
          >
            {{ row.type.name }}
          </div>
        </div>
    
        <div class="description">
        {{ pokemon.description }}
        </div>
    
        <a @click="moreDetails" class="link">More Details</a>
      </div>
    </template>
    
    <script>
    export default {
      name: "BasicDetails",
      props: {
        pokemon: {
          type: Object,
        },
      },
    
      methods: {
        moreDetails() {
          this.$router.push({
            name: "PokemonDetails",
            params: {
              name: this.pokemon.name,
              specie_id: this.pokemon.specie_id,
            },
          });
        },
      },
    };
    </script>
    
    <style lang="scss" scoped>
    @import "../styles/types.scss";
    @import "../styles/pokemon.scss";
    </style>
    

    You can check out the code for the styles/types.scss and styles/pokemon.scss file in the GitHub repo.

    At this point, you should be able to see the changes in the browser again. You can also update the components/List.vue file back to its original code with the router-link on it instead of the <div>.

    Programmatic Navigation

    You might have noticed that we’ve done something different in the BasicDetails component. We didn’t really navigate to the PokemonDetails page using router-link. Instead, we used an anchor element and intercepted its click event. This is how programmatic navigation is implemented. We can get access to the router via this.$router. Then we call the push() method to push a new page on top of the history stack. Whatever page is on top will be displayed by the router. This method allows for navigating back to the previous page when the user clicks on the browser’s back button, since clicking it simply “pops” the current page on top of the history stack. This method accepts an object containing the name and params properties, so it’s pretty much the same thing you pass to the to property in the router-link:

    methods: {
      moreDetails() {
        this.$router.push({
          name: "PokemonDetails",
          params: {
            name: this.pokemon.name,
            specie_id: this.pokemon.specie_id,
          },
        });
      },
    },
    

    Nested routes

    Next, update the router file to include the path for the Pokemon details page. Here, we’re using nested routes to pass in more than one custom parameter. In this case, we’re passing in the name and specie_id:

    import Pokemon from "../views/Pokemon";
    
    import PokemonDetails from "../views/PokemonDetails"; // add this
    
    const routes = [
      // ..
      {
        path: "/pokemon/:name",
        // ..
      },
    
      // add these
      {
        path: "/pokemon/:name/:specie_id/details",
        name: "PokemonDetails",
        component: PokemonDetails,
      },
    ];
    

    Here’s the code for the PokemonDetails page (views/PokemonDetails.vue):

    <template>
      <MoreDetails :pokemon="pokemon" />
    </template>
    <script>
    import axios from "axios";
    import MoreDetails from "../components/MoreDetails.vue";
    
    export default {
      name: "PokemonDetails",
      data() {
        return {
          pokemon: null,
        };
      },
      mounted() {
        const pokemon_name = this.$route.params.name;
    
        axios
          .get(`https://pokeapi.co/api/v2/pokemon/${pokemon_name}`)
          .then((res) => {
            const data = res.data;
    
            axios.get(`https://pokeapi.co/api/v2/evolution-chain/${this.$route.params.specie_id}`)
            .then((res) => {
              let evolution_chain = [res.data.chain.species.name];
    
              if (res.data.chain.evolves_to.length > 0) {
                evolution_chain.push(
                  res.data.chain.evolves_to[0].species.name
                );
    
                if (res.data.chain.evolves_to.length > 1) {
                  const evolutions = res.data.chain.evolves_to.map((item) => {
                    return item.species.name;
                  }
                );
    
                evolution_chain[1] = evolutions.join(" | ");
              }
    
              if (
                res.data.chain.evolves_to[0].evolves_to.length >
                0
              ) {
                evolution_chain.push(res.data.chain.evolves_to[0].evolves_to[0].species.name);
              }
    
                Object.assign(data, {
                  evolution_chain,
                });
              }
    
              this.pokemon = data;
            });
        });
      },
      components: {
        MoreDetails,
      },
    };
    </script>
    

    Here’s the code for the MoreDetails components (components/MoreDetails.vue):

    <template>
      <div v-if="pokemon">
        <h1>{{ pokemon.name }}</h1>
    
        <div v-if="pokemon.evolution_chain" class="section">
          <h2>Evolution Chain</h2>
          <span v-for="(name, index) in pokemon.evolution_chain" :key="name">
            <span v-if="index">-></span>
            {{ name }}
          </span>
        </div>
    
        <div v-if="pokemon.abilities" class="section">
          <h2>Abilities</h2>
    
          <div v-for="row in pokemon.abilities" :key="row.ability.name">
            {{ row.ability.name }}
          </div>
        </div>
    
        <div v-if="pokemon.moves" class="section">
          <h2>Moves</h2>
          <div v-for="row in pokemon.moves" :key="row.move.name">
            {{ row.move.name }}
          </div>
        </div>
      </div>
    </template>
    
    <script>
    export default {
      name: "MoreDetails",
      props: {
        pokemon: {
          type: Object,
        },
      },
    };
    </script>
    
    <style lang="scss" scoped>
    @import "../styles/more-details.scss";
    </style>
    

    You can view the contents of the styles/more-details.scss file on the GitHub repo.

    At this point you can click on any of the Pokemon names and view the details of an individual Pokemon. You might need to restart the server to see the changes.

    404 page

    We’ve added the code for all of the pages. But what happens if the user enters an invalid URL to the browser’s address bar? In those cases, it will simply error out or not display anything at all. We need to add a way to intercept those requests so we can display a “404 not found” page.

    To do that, open the router file and import the NotFound page:

    import NotFound from "../views/NotFound";
    

    Routes are prioritized based on the order they’re added in the routes array. This means that the ones added first are the first ones being matched with the URL entered by the user on the address bar. So the pattern for the 404 page has to be added last.

    In the routes array, add the following:

    const routes = [
      // ..
      {
        path: "/pokemon/:name/:specie_id/details",
        // ..
      },
    
      // add this
      {
        path: "/:pathMatch(.*)*",
        name: "NotFound",
        component: NotFound,
      },
    ];
    

    Does the path look familiar? We’re using a custom param named pathMatch to match for whatever URL is entered. So if the user entered http://localhost:8000/hey or http://localhost:8000/hey/jude, it would render the NotFound page.

    This is all well and good. But what happens if the patterns above the catch-all pattern are actually matched? For example:

    • http://localhost:8000/pokemon/someinvalidpokemon
    • http://localhost:8000/pokemon/someinvalidpokemon/99999/details

    In these cases, the catch-all pattern wouldn’t match, so we need a way to intercept such requests.

    The main issue with those kinds of requests is that the user is assuming that a certain Pokemon or species ID exists, but it doesn’t. The only way to check is to have a list of valid Pokemon. In your routes file, import the list of valid Pokemon:

    import NotFound from "../views/NotFound";
    
    import valid_pokemon from "../data/valid-pokemon.json"; // add this
    

    You can find this file on the GitHub repo.

    To intercept these kinds of requests, Vue Router provides navigation guards. Think of them as “hooks” to the navigation process that allow you to execute certain actions before or after Vue Router has navigated to a certain page. We’ll only be going through the one executed before the navigation is done, as this allows us to redirect to a different page if our condition for navigating to that page isn’t matched.

    To hook into the current request before the navigation is done, we call the beforeEach() method on the router instance:

    const router = createRouter({
      // ..
    });
    
    router.beforeEach(async (to) => {
      // next: add the condition for navigating to the 404 page
    });
    

    Vue Router passes two arguments to it:

    • to: the target route location
    • from: the current route location

    Each one contains these properties. What we’re interested in is the params, as this contains whatever params the user has passed in the URL.

    Here’s what our condition looks like. We first check whether the params we want to check exists. If it does, we proceed to check if it’s valid. The first condition matches for the Pokemon page. We use the valid_pokemon array from earlier. We compare it with to.params.name, which contains the name of the Pokemon passed by the user. On the other hand, the second condition matches for the PokemonDetails page. Here we’re checking for the species ID. As we only want to match the original 101 Pokemon, any ID that’s greater than that is considered invalid. If it matches any of these conditions, we simply return the path to the 404 page. If the conditions didn’t match, it will navigate to where its originally meant to navigate to:

    if (
      to.params &&
      to.params.name &&
      valid_pokemon.indexOf(to.params.name) === -1
    ) {
      return "/404";
    }
    
    if (
      (to.params &&
        to.params.name &&
        to.params.specie_id &&
        valid_pokemon.indexOf(to.params.name) === -1 &&
        to.params.specie_id < 0) ||
      to.params.specie_id > 101
    ) {
      return "/404";
    }
    

    Here’s the code for the 404 page (views/NotFound.vue):

    <template>
      <h1>404 Not Found</h1>
    </template>
    <script>
    export default {
      name: "Not Found",
    };
    </script>
    <style lang="scss" scoped>
    @import "../styles/notfound.scss";
    </style>
    

    You can view the code for the styles/notfound.scss file on the GitHub repo.

    At this point, the app is complete! You can try visiting invalid pages and it will return a 404 page.

    Conclusion

    That’s it! In this tutorial, you learned the basics of using Vue Router. Things like setting up a router, passing custom parameters, navigating between pages, and implementing a 404 page will bring you a long way. If you want some direction on where to go from here, I recommend exploring the following topics:

    • Passing props to route components: allows you to decouple your view components from the route params. This provides a way to swap the route params with props that can be accessed from the component. That way you can use your components anywhere which doesn’t have $route.params.
    • Transitions: for animating the transition between pages.
    • Lazy loading: this is more of a performance improvement so the bundler doesn’t compile the codes for all the pages in a single file. Instead, it will lazy load it so that the browser only downloads the code for a specific page once it’s needed.