Back to journal
·2 min readVue.jsVueVue RouterNavigationComponentsDynamic

Dynamic Menu in Vue

Build a sidebar from Vue Router routes with meta.visible and a recursive MenuItem component. Vue CLI 3 era, still useful pattern.

ShareCopy failed

Every Vue app has navigation. When routes multiply, hand-writing menu links gets old fast.

I hit this on a side project with nested routes. Here's the pattern: read routes from the router, filter with meta.visible, recurse on children.

Source · Demo

Scaffold

vue create dynamic-menu-vue

Pick Router, history mode, Sass, lint on save. Then:

npm run serve -- --open

Router setup

meta.visible controls menu visibility. Lazy imports keep bundles smaller. base is set for GitHub Pages.

import Vue from "vue";
import Router from "vue-router";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: "/dynamic-menu-vue/",
  routes: [
    {
      path: "/",
      redirect: { path: "/home" },
      meta: {
        visible: false,
      },
    },
    {
      path: "/home",
      name: "home",
      component: () => import(/* webpackChunkName: "home" */ "./views/Home.vue"),
      meta: {
        visible: true,
      },
      children: [
        {
          path: "sub-view-1",
          name: "sub-view-1",
          component: () =>
            import(/* webpackChunkName: "home-sub-view-1" */ "./components/Home/SubView1.vue"),
          meta: {
            visible: true,
          },
        },
        {
          path: "sub-view-2",
          name: "sub-view-2",
          component: () =>
            import(/* webpackChunkName: "home-sub-view-2" */ "./components/Home/SubView2.vue"),
          meta: {
            visible: true,
          },
        },
      ],
    },
    {
      path: "/about",
      name: "about",
      component: () => import(/* webpackChunkName: "about" */ "./views/About.vue"),
      meta: {
        visible: true,
      },
    },
    {
      path: "*",
      name: "not-found",
      component: () => import(/* webpackChunkName: "not-found" */ "./views/NotFound.vue"),
      meta: {
        visible: false,
      },
    },
  ],
});

Bootstrap styles

npm install bootstrap --save

src/styles.scss:

@import "./assets/variables";
@import "node_modules/bootstrap/scss/bootstrap.scss";
@import "./assets/bootswatch";

Import in main.js:

import "./styles.scss";

Navbar + recursive MenuItem

src/components/Menu/Navbar.vue:

<template>
  <nav class="nav flex-column p-3">
    <menu-item v-for="(r, i) in routes" :key="i" :route="r"></menu-item>
  </nav>
</template>

<script>
export default {
  name: "navbar",
  components: {
    MenuItem: () => import(/* webpackChunkName: "menu-item" */ "./MenuItem"),
  },
  computed: {
    routes() {
      return this.$router.options.routes;
    },
  },
};
</script>

MenuItem.vue checks visibility, prettifies the route name, and recurses into children:

<template>
  <div>
    <li v-if="isVisible" class="nav-item rounded shadow-sm mb-2">
      <router-link exact-active-class="text-success" :to="{ name: route.name }" class="nav-link">{{
        name
      }}</router-link>
    </li>

    <div v-if="route.children && route.children.length">
      <menu-item v-for="(r, i) in route.children" :key="i" :route="r" class="ml-3"></menu-item>
    </div>
  </div>
</template>

<script>
export default {
  name: "menu-item",
  props: {
    route: {
      type: Object,
    },
  },
  computed: {
    isVisible() {
      if (this.route.meta && (this.route.meta.visible === undefined || this.route.meta.visible)) {
        return true;
      }
      return false;
    },
    name() {
      return this.route.name
        .toLowerCase()
        .split("-")
        .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
        .join(" ");
    },
  },
};
</script>

exact-active-class highlights the current link. Nested routes indent with ml-3.

That's the core loop: one source of truth in the router table, menu follows automatically.

Questions welcome. Happy coding.

ShareCopy failed