diff --git a/shells/dev/target/NativeTypes.vue b/shells/dev/target/NativeTypes.vue
index 77674e464..11541db3d 100644
--- a/shells/dev/target/NativeTypes.vue
+++ b/shells/dev/target/NativeTypes.vue
@@ -12,7 +12,12 @@
 
     <p>
       <button @click="sendComponent()">Vuex mutation</button>
-      <button @click="createLargeArray()">Create large array</button>
+      <button
+        style="background: red; color: white;"
+        @click="createLargeArray()"
+      >
+        Create large array
+      </button>
     </p>
 
     <h3>Set</h3>
diff --git a/shells/dev/target/Router.vue b/shells/dev/target/Router.vue
deleted file mode 100644
index 935822146..000000000
--- a/shells/dev/target/Router.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-<template>
-  <div id="router">
-    <h1>Router</h1>
-    <nav>
-      <router-link :to="{ name: 'page1' }" exact>Page 1</router-link>
-      <router-link :to="{ name: 'page2', params: { id: 42 } }" exact>Page 2</router-link>
-    </nav>
-    <keep-alive>
-      <router-view />
-    </keep-alive>
-  </div>
-</template>
-
-<style scoped>
-a {
-  margin-right: 6px;
-}
-
-.router-link-active {
-  font-weight: bold;
-}
-</style>
-
diff --git a/shells/dev/target/index.js b/shells/dev/target/index.js
index 704eb84f1..71832abba 100644
--- a/shells/dev/target/index.js
+++ b/shells/dev/target/index.js
@@ -7,8 +7,8 @@ import VuexObject from './VuexObject.vue'
 import NativeTypes from './NativeTypes.vue'
 import Events from './Events.vue'
 import MyClass from './MyClass.js'
-import Router from './Router.vue'
 import router from './router'
+import Router from './router/Router.vue'
 
 window.VUE_DEVTOOLS_CONFIG = {
   openInEditorHost: '/'
@@ -25,6 +25,12 @@ circular.self = circular
 new Vue({
   store,
   router,
+  data: {
+    obj: {
+      items: items,
+      circular
+    }
+  },
   render (h) {
     return h('div', null, [
       h(Counter),
@@ -35,12 +41,6 @@ new Vue({
       h(Router, { key: [] }),
       h(VuexObject)
     ])
-  },
-  data: {
-    obj: {
-      items: items,
-      circular
-    }
   }
 }).$mount('#app')
 
diff --git a/shells/dev/target/router.js b/shells/dev/target/router.js
index d0a703526..1b37e0d8f 100644
--- a/shells/dev/target/router.js
+++ b/shells/dev/target/router.js
@@ -1,13 +1,49 @@
 import Vue from 'vue'
 import VueRouter from 'vue-router'
-import Page1 from './Page1.vue'
-import Page2 from './Page2.vue'
+import RouteOne from './router/RouteOne.vue'
+import RouteTwo from './router/RouteTwo.vue'
+import RouteWithParams from './router/RouteWithParams.vue'
+import NamedRoute from './router/NamedRoute.vue'
+import RouteWithQuery from './router/RouteWithQuery.vue'
+import RouteWithBeforeEnter from './router/RouteWithBeforeEnter.vue'
+import RouteWithAlias from './router/RouteWithAlias.vue'
+import RouteWithProps from './router/RouteWithProps.vue'
+import ParentRoute from './router/ParentRoute.vue'
+import ChildRoute from './router/ChildRoute.vue'
 
 Vue.use(VueRouter)
 
+const DynamicComponent = {
+  render: (h) => h('div', 'Hello from dynamic component')
+}
+
 const routes = [
-  { path: '/', name: 'page1', component: Page1 },
-  { path: '/page2', name: 'page2', component: Page2 }
+  { path: '/route-one', component: RouteOne },
+  { path: '/route-two', component: RouteTwo },
+  { path: '/route-with-params/:username/:id', component: RouteWithParams },
+  { path: '/route-named', component: NamedRoute, name: 'NamedRoute' },
+  { path: '/route-with-query', component: RouteWithQuery },
+  { path: '/route-with-before-enter',
+    component: RouteWithBeforeEnter,
+    beforeEnter: (to, from, next) => {
+      next()
+    }},
+  { path: '/route-with-redirect', redirect: '/route-one' },
+  { path: '/route-with-alias', component: RouteWithAlias, alias: '/this-is-the-alias' },
+  { path: '/route-with-dynamic-component', component: DynamicComponent, props: true },
+  { path: '/route-with-props',
+    component: RouteWithProps,
+    props: {
+      username: 'My Username',
+      id: 99
+    }},
+  { path: '/route-with-props-default', component: RouteWithProps },
+  { path: '/route-parent',
+    component: ParentRoute,
+    children: [
+      { path: '/route-child', component: ChildRoute }
+    ]
+  }
 ]
 
 const router = new VueRouter({
diff --git a/shells/dev/target/router/ChildRoute.vue b/shells/dev/target/router/ChildRoute.vue
new file mode 100644
index 000000000..3337f2ee4
--- /dev/null
+++ b/shells/dev/target/router/ChildRoute.vue
@@ -0,0 +1,9 @@
+<template>
+  <h2>Child route</h2>
+</template>
+
+<script>
+export default {
+
+}
+</script>
diff --git a/shells/dev/target/router/NamedRoute.vue b/shells/dev/target/router/NamedRoute.vue
new file mode 100644
index 000000000..e68fd580b
--- /dev/null
+++ b/shells/dev/target/router/NamedRoute.vue
@@ -0,0 +1,10 @@
+<template>
+  <div>
+    <p>Hello named route</p>
+  </div>
+</template>
+
+<script>
+export default {
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/ParentRoute.vue b/shells/dev/target/router/ParentRoute.vue
new file mode 100644
index 000000000..27138e6d6
--- /dev/null
+++ b/shells/dev/target/router/ParentRoute.vue
@@ -0,0 +1,12 @@
+<template>
+  <div class="parent">
+    <h2>Parent</h2>
+    <router-view class="child"></router-view>
+  </div>
+</template>
+
+<script>
+export default {
+
+}
+</script>
diff --git a/shells/dev/target/router/RouteOne.vue b/shells/dev/target/router/RouteOne.vue
new file mode 100644
index 000000000..b131fd159
--- /dev/null
+++ b/shells/dev/target/router/RouteOne.vue
@@ -0,0 +1,11 @@
+<template>
+  <div>
+    <p>Hello from route 1</p>
+  </div>
+</template>
+
+<script>
+export default {
+
+}
+</script>
diff --git a/shells/dev/target/router/RouteTwo.vue b/shells/dev/target/router/RouteTwo.vue
new file mode 100644
index 000000000..ffa59b0ad
--- /dev/null
+++ b/shells/dev/target/router/RouteTwo.vue
@@ -0,0 +1,11 @@
+<template>
+  <div>
+    <p>Hello from route 2</p>
+  </div>
+</template>
+
+<script>
+export default {
+
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/RouteWithAlias.vue b/shells/dev/target/router/RouteWithAlias.vue
new file mode 100644
index 000000000..0a67a2ec8
--- /dev/null
+++ b/shells/dev/target/router/RouteWithAlias.vue
@@ -0,0 +1,10 @@
+<template>
+  <div>
+    <p>Hello from route with alias</p>
+  </div>
+</template>
+
+<script>
+export default {
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/RouteWithBeforeEnter.vue b/shells/dev/target/router/RouteWithBeforeEnter.vue
new file mode 100644
index 000000000..479291b0a
--- /dev/null
+++ b/shells/dev/target/router/RouteWithBeforeEnter.vue
@@ -0,0 +1,10 @@
+<template>
+  <div>
+    <p>Hello from before enter route</p>
+  </div>
+</template>
+
+<script>
+export default {
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/RouteWithParams.vue b/shells/dev/target/router/RouteWithParams.vue
new file mode 100644
index 000000000..0b8db1849
--- /dev/null
+++ b/shells/dev/target/router/RouteWithParams.vue
@@ -0,0 +1,11 @@
+<template>
+  <div>
+    <p>Hello from route with params: Username: {{ $route.params.username }}, Id: {{ $route.params.id }}</p>
+  </div>
+</template>
+
+<script>
+export default {
+
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/RouteWithProps.vue b/shells/dev/target/router/RouteWithProps.vue
new file mode 100644
index 000000000..34e7af10a
--- /dev/null
+++ b/shells/dev/target/router/RouteWithProps.vue
@@ -0,0 +1,20 @@
+<template>
+  <div>
+    <p>Hello from route with props: Username: {{ username }}, Id: {{ id }}</p>
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    username: {
+      type: String,
+      default: 'ms'
+    },
+    id: {
+      type: Number,
+      default: 33
+    }
+  }
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/RouteWithQuery.vue b/shells/dev/target/router/RouteWithQuery.vue
new file mode 100644
index 000000000..759da6f26
--- /dev/null
+++ b/shells/dev/target/router/RouteWithQuery.vue
@@ -0,0 +1,10 @@
+<template>
+  <div>
+    <p>Hello from route with query: {{ $route.query }}</p>
+  </div>
+</template>
+
+<script>
+export default {
+}
+</script>
\ No newline at end of file
diff --git a/shells/dev/target/router/Router.vue b/shells/dev/target/router/Router.vue
new file mode 100644
index 000000000..ffdb05fb3
--- /dev/null
+++ b/shells/dev/target/router/Router.vue
@@ -0,0 +1,35 @@
+<template>
+  <div>
+    <p><router-link to="/route-one">Go to Route One</router-link></p>
+    <p><router-link to="/route-two">Go to Route Two</router-link></p>
+    <p><router-link to="/route-with-params/markussorg/5">Go to route with params</router-link></p>
+    <p><router-link to="/route-named">Go to named route</router-link></p>
+    <p><router-link to="/route-with-query?el=value&test=true">Go to route with query</router-link></p>
+    <p><router-link to="/route-with-before-enter">Go to route with before enter</router-link></p>
+    <p><router-link to="/route-with-redirect">Go to route with redirect</router-link></p>
+    <p><router-link to="/this-is-the-alias">Go to route with alias</router-link></p>
+    <p><router-link to="/route-with-dynamic-component">Go to route with dyn. component</router-link></p>
+    <p><router-link to="/route-with-props">Go to route with props</router-link></p>
+    <p><router-link to="/route-with-props-default">Go to route with props (default)</router-link></p>
+    <p><router-link to="/route-parent">Go to route parent</router-link></p>
+    <p><router-link to="/route-child">Go to route child</router-link></p>
+    <keep-alive>
+      <router-view />
+    </keep-alive>
+    <p><button @click="addRoutes">Add new routes</button></p>
+  </div>
+</template>
+
+<script>
+import RouteOne from './RouteOne.vue'
+
+export default {
+  methods: {
+    addRoutes () {
+      this.$router.addRoutes([
+        { path: '/new-route', component: RouteOne }
+      ])
+    } 
+  }
+}
+</script>
diff --git a/src/backend/index.js b/src/backend/index.js
index 489c94dba..b7ca82eea 100644
--- a/src/backend/index.js
+++ b/src/backend/index.js
@@ -4,6 +4,7 @@
 import { highlight, unHighlight, getInstanceOrVnodeRect } from './highlighter'
 import { initVuexBackend } from './vuex'
 import { initEventsBackend } from './events'
+import { initRouterBackend } from './router'
 import { initPerfBackend } from './perf'
 import { findRelatedComponent } from './utils'
 import { stringify, classify, camelize, set, parse, getComponentName } from '../util'
@@ -121,6 +122,10 @@ function connect (Vue) {
     })
   }
 
+  hook.once('router:init', () => {
+    initRouterBackend(hook.Vue, bridge, rootInstances)
+  })
+
   // events
   initEventsBackend(Vue, bridge)
 
@@ -206,6 +211,7 @@ function scan () {
       return true
     }
   })
+  hook.emit('router:init')
   flush()
 }
 
@@ -340,7 +346,7 @@ function capture (instance, index, list) {
     captureCount++
   }
 
-  if (instance.$options && instance.$options.abstract) {
+  if (instance.$options && instance.$options.abstract && instance._vnode.componentInstance) {
     instance = instance._vnode.componentInstance
   }
 
diff --git a/src/backend/perf.js b/src/backend/perf.js
index f52511adb..ec29d0a49 100644
--- a/src/backend/perf.js
+++ b/src/backend/perf.js
@@ -95,8 +95,10 @@ function applyHooks (vm) {
         if (renderHook && renderHook.before) {
           // Render hook ends before one hook
           const metric = renderMetrics[renderHook.before]
-          metric.end = time
-          addComponentMetric(vm.$options, renderHook.before, metric.start, metric.end)
+          if (metric) {
+            metric.end = time
+            addComponentMetric(vm.$options, renderHook.before, metric.start, metric.end)
+          }
         }
 
         // After
diff --git a/src/backend/router.js b/src/backend/router.js
index a7a9f1970..a5af0a539 100644
--- a/src/backend/router.js
+++ b/src/backend/router.js
@@ -1,3 +1,61 @@
+import { stringify } from '../util'
+
+export function initRouterBackend (Vue, bridge, rootInstances) {
+  let recording = true
+
+  const getSnapshot = () => {
+    const routeChanges = []
+    rootInstances.forEach(instance => {
+      const router = instance._router
+      if (router && router.options && router.options.routes) {
+        routeChanges.push(...router.options.routes)
+      }
+    })
+    return stringify({
+      routeChanges
+    })
+  }
+
+  bridge.send('routes:init', getSnapshot())
+
+  bridge.on('router:toggle-recording', enabled => {
+    recording = enabled
+  })
+
+  rootInstances.forEach(instance => {
+    const router = instance._router
+
+    if (router) {
+      router.afterEach((to, from) => {
+        if (!recording) return
+        bridge.send('router:changed', stringify({
+          to,
+          from,
+          timestamp: Date.now()
+        }))
+      })
+      bridge.send('router:init', stringify({
+        mode: router.mode,
+        current: {
+          from: router.history.current,
+          to: router.history.current,
+          timestamp: Date.now()
+        }
+      }))
+
+      if (router.matcher && router.matcher.addRoutes) {
+        const addRoutes = router.matcher.addRoutes
+        router.matcher.addRoutes = function (routes) {
+          routes.forEach((item) => {
+            bridge.send('routes:changed', stringify(item))
+          })
+          addRoutes.call(this, routes)
+        }
+      }
+    }
+  })
+}
+
 export function getCustomRouterDetails (router) {
   return {
     _custom: {
diff --git a/src/devtools/App.vue b/src/devtools/App.vue
index 631b1392b..11e3da1a9 100644
--- a/src/devtools/App.vue
+++ b/src/devtools/App.vue
@@ -76,9 +76,46 @@
             value="events"
             icon-left="grain"
             class="events-tab flat big-tag"
+            @focus.native="isRouterGroupOpen = false"
           >
             Events
           </VueGroupButton>
+          <GroupDropdown
+            v-tooltip="$t('App.routing.tooltip')"
+            :is-open="isRouterGroupOpen"
+            :options="routingTabs"
+            :value="routeModel"
+            @update="isRouterGroupOpen = $event"
+            @select="routeModel = $event"
+          >
+            <template slot="header">
+              <VueIcon
+                icon="directions"
+                style="margin-right: 6px"
+              />
+              <span class="hide-below-wide">
+                Routing
+              </span>
+              <VueIcon
+                icon="keyboard_arrow_down"
+                style="margin-left: 6px"
+              />
+            </template>
+            <template
+              slot="option"
+              slot-scope="{ option }"
+            >
+              <VueGroupButton
+                :value="option.name"
+                :icon-left="option.icon"
+                style="width: 100%;"
+                class="events-tab flat big-tag"
+                @selected="isRouterGroupOpen = false"
+              >
+                {{ option.label }}
+              </VueGroupButton>
+            </template>
+          </GroupDropdown>
           <VueGroupButton
             v-tooltip="$t('App.perf.tooltip')"
             :class="{
@@ -98,6 +135,7 @@
             value="settings"
             icon-left="settings_applications"
             class="settings-tab flat"
+            @focus.native="isRouterGroupOpen = false"
           >
             Settings
           </VueGroupButton>
@@ -126,8 +164,11 @@
 import ComponentsTab from './views/components/ComponentsTab.vue'
 import EventsTab from './views/events/EventsTab.vue'
 import VuexTab from './views/vuex/VuexTab.vue'
+import RouterTab from './views/router/RouterTab.vue'
+import RoutesTab from './views/routes/RoutesTab.vue'
 import { SPECIAL_TOKENS } from '../util'
 import Keyboard from './mixins/keyboard'
+import GroupDropdown from 'components/GroupDropdown.vue'
 
 import { mapState } from 'vuex'
 
@@ -137,7 +178,10 @@ export default {
   components: {
     components: ComponentsTab,
     vuex: VuexTab,
-    events: EventsTab
+    events: EventsTab,
+    router: RouterTab,
+    routes: RoutesTab,
+    GroupDropdown
   },
 
   mixins: [
@@ -161,9 +205,15 @@ export default {
               this.$router.push({ name: 'events' })
               return false
             } else if (code === 'Digit4') {
+              if (this.$route.name !== 'router') {
+                this.$router.push({ name: 'router' })
+              } else {
+                this.$router.push({ name: 'routes' })
+              }
+            } else if (code === 'Digit5') {
               this.$router.push({ name: 'perf' })
               return false
-            } else if (code === 'Digit5') {
+            } else if (code === 'Digit6') {
               this.$router.push({ name: 'settings' })
               return false
             } else if (key === 'p' || code === 'KeyP') {
@@ -175,6 +225,16 @@ export default {
     })
   ],
 
+  data () {
+    return {
+      isRouterGroupOpen: false,
+      routingTabs: [
+        { name: 'router', label: 'History', icon: 'directions' },
+        { name: 'routes', label: 'Routes', icon: 'book' }
+      ]
+    }
+  },
+
   computed: {
     ...mapState({
       message: state => state.message,
@@ -326,4 +386,8 @@ export default {
 .container
   overflow hidden
   flex 1
+
+.hide-below-wide
+  @media (max-width: $wide)
+    display: none
 </style>
diff --git a/src/devtools/components/GroupDropdown.vue b/src/devtools/components/GroupDropdown.vue
new file mode 100644
index 000000000..cbd41dd5b
--- /dev/null
+++ b/src/devtools/components/GroupDropdown.vue
@@ -0,0 +1,114 @@
+<template>
+  <div
+    class="group-dropdown"
+    :tabindex="isOpen ? -1 : 0"
+    :class="{
+      'selected': isValueInOptions
+    }"
+    @mouseenter="$emit('update', true)"
+    @mouseleave="$emit('update', false)"
+    @focus="$emit('update', true)"
+  >
+    <div
+      class="header"
+      @click="selectDefault"
+    >
+      <slot name="header" />
+    </div>
+
+    <div
+      v-show="isOpen"
+      class="group-dropdown-options"
+    >
+      <template v-for="option of options">
+        <slot
+          :ref="option.name"
+          name="option"
+          :option="option"
+        />
+      </template>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    isOpen: {
+      type: Boolean,
+      default: false
+    },
+
+    options: {
+      type: Array,
+      required: true
+    },
+
+    value: {
+      type: String,
+      required: true
+    }
+  },
+
+  computed: {
+    isValueInOptions () {
+      return this.options.find(
+        option => option.name === this.value
+      )
+    }
+  },
+
+  watch: {
+    isOpen (isOpen) {
+      if (isOpen) {
+        window.addEventListener('click', this.outsideClickHandler)
+      } else {
+        window.removeEventListener('click', this.outsideClickHandler)
+      }
+    }
+  },
+
+  methods: {
+    outsideClickHandler ($event) {
+      if (!this.$el.contains($event.target)) {
+        this.$emit('update', false)
+      }
+    },
+
+    selectDefault () {
+      this.$emit('select', this.options[0].name)
+      this.$el.blur()
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.group-dropdown
+  position relative
+  z-index 100
+  &:focus
+    outline none
+    .vue-ui-dark-mode &
+      background darken($vue-ui-color-dark, 8%)
+  .header
+    display flex
+    align-items center
+    padding 0 14px
+    height 48px
+    cursor pointer
+  & /deep/ svg
+    fill #2c3e50
+
+.group-dropdown-options
+  position absolute
+  background white
+  left 0
+  top 48px
+  width 100%
+  box-shadow 0 3px 6px rgba(0,0,0,0.15)
+  border-bottom-left-radius 3px
+  border-bottom-right-radius 3px
+  .vue-ui-dark-mode &
+    background lighten($vue-ui-color-darker, 3%)
+</style>
diff --git a/src/devtools/components/SplitPane.vue b/src/devtools/components/SplitPane.vue
index 5057ddc7e..fc80b37cc 100644
--- a/src/devtools/components/SplitPane.vue
+++ b/src/devtools/components/SplitPane.vue
@@ -154,5 +154,4 @@ export default {
     bottom -5px
     height 10px
     cursor ns-resize
-
 </style>
diff --git a/src/devtools/components/TriplePane.vue b/src/devtools/components/TriplePane.vue
new file mode 100644
index 000000000..2102b0677
--- /dev/null
+++ b/src/devtools/components/TriplePane.vue
@@ -0,0 +1,73 @@
+<template>
+  <div style="height: 100%;" class="split-pane"
+    @mousemove="dragMove"
+    @mouseup="dragEnd"
+    @mouseleave="dragEnd"
+    :class="{ dragging: dragging }">
+    <div class="left" :style="{ width: widthLeft + '%' }">
+      <slot name="left"></slot>
+      <div class="dragger" @mousedown="dragStartLeft"></div>
+    </div>
+    <div class="middle" :style="{ width: widthMiddle + '%' }">
+      <slot name="middle"></slot>
+      <div class="dragger" @mousedown="dragStartRight"></div>
+    </div>
+    <div class="right" :style="{ width: widthRight + '%' }">
+      <slot name="right"></slot>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  data () {
+    return {
+      widthLeft: 20,
+      widthMiddle: 40,
+      widthRight: 40,
+      draggingLeft: false,
+      draggingRight: false
+    }
+  },
+  computed: {
+    dragging () {
+      return this.draggingLeft && this.draggingRight
+    }
+  },
+  methods: {
+    dragStartLeft (e) {
+      this.draggingLeft = true
+      this.startXLeft = e.pageX
+      this.startSplitLeft = this.widthLeft
+      this.startSplitMiddle = this.widthMiddle
+    },
+    dragStartRight (e) {
+      this.draggingRight = true
+      this.startXRight = e.pageX
+      this.startSplitRight = this.widthRight
+      this.startSplitMiddle = this.widthMiddle
+    },
+    dragMove (e) {
+      if (this.draggingLeft) {
+        const diff = this.getDiff(e, this.startXLeft)
+        this.widthLeft = this.startSplitLeft + diff
+        this.widthMiddle = this.startSplitMiddle - diff
+      }
+      if (this.draggingRight) {
+        const diff = this.getDiff(e, this.startXRight)
+        this.widthMiddle = this.startSplitMiddle + diff
+        this.widthRight = this.startSplitRight - diff
+      }
+    },
+    getDiff (e, start) {
+      const dx = e.pageX - start
+      const totalWidth = this.$el.offsetWidth
+      return ~~(dx / totalWidth * 100)
+    },
+    dragEnd () {
+      this.draggingLeft = false
+      this.draggingRight = false
+    }
+  }
+}
+</script>
diff --git a/src/devtools/components/splitPanes.styl b/src/devtools/components/splitPanes.styl
new file mode 100644
index 000000000..2a5ba77c5
--- /dev/null
+++ b/src/devtools/components/splitPanes.styl
@@ -0,0 +1,22 @@
+.split-pane
+  display flex
+  height 100%
+  &.dragging
+    cursor ew-resize
+
+.left, .right, .middle
+  position relative
+
+.left, .middle
+  border-right 1px solid $border-color
+  .app.dark &
+    border-right 1px solid $dark-border-color
+
+.dragger
+  position absolute
+  z-index 99
+  top 0
+  bottom 0
+  right -5px
+  width 10px
+  cursor ew-resize
\ No newline at end of file
diff --git a/src/devtools/index.js b/src/devtools/index.js
index fcef10edb..cdd71b573 100644
--- a/src/devtools/index.js
+++ b/src/devtools/index.js
@@ -169,6 +169,27 @@ function initApp (shell) {
       }
     })
 
+    bridge.on('router:init', payload => {
+      store.commit('router/INIT', parse(payload))
+    })
+
+    bridge.on('router:changed', payload => {
+      store.commit('router/CHANGED', parse(payload))
+    })
+
+    bridge.on('routes:init', payload => {
+      store.commit('routes/INIT', parse(payload))
+    })
+
+    bridge.on('routes:changed', payload => {
+      store.commit('routes/CHANGED', parse(payload))
+    })
+
+    // register filters
+    Vue.filter('formatTime', function (timestamp) {
+      return (new Date(timestamp)).toString().match(/\d\d:\d\d:\d\d/)[0]
+    })
+
     bridge.on('events:reset', () => {
       store.commit('events/RESET')
     })
diff --git a/src/devtools/locales/en.js b/src/devtools/locales/en.js
index 922129717..d211f749a 100644
--- a/src/devtools/locales/en.js
+++ b/src/devtools/locales/en.js
@@ -9,11 +9,14 @@ export default {
     refresh: {
       tooltip: '[[{{keys.ctrl}}]] + [[{{keys.alt}}]] + [[R]] Force Refresh'
     },
+    routing: {
+      tooltip: '[[{{keys.ctrl}}]] + [[4]] Switch to Routing'
+    },
     perf: {
-      tooltip: '[[{{keys.ctrl}}]] + [[4]] Switch to Performance'
+      tooltip: '[[{{keys.ctrl}}]] + [[5]] Switch to Performance'
     },
     settings: {
-      tooltip: '[[{{keys.ctrl}}]] + [[5]] Switch to Settings'
+      tooltip: '[[{{keys.ctrl}}]] + [[6]] Switch to Settings'
     },
     vuex: {
       tooltip: '[[{{keys.ctrl}}]] + [[2]] Switch to Vuex'
diff --git a/src/devtools/router.js b/src/devtools/router.js
index d93f93503..7b7d24062 100644
--- a/src/devtools/router.js
+++ b/src/devtools/router.js
@@ -8,6 +8,8 @@ import PerfTab from './views/perf/PerfTab.vue'
 import ComponentRenderStats from './views/perf/ComponentRenderStats.vue'
 import FramerateGraph from './views/perf/FramerateGraph.vue'
 import SettingsTab from './views/settings/SettingsTab.vue'
+import RouterTab from './views/router/RouterTab.vue'
+import RoutesTab from './views/routes/RoutesTab.vue'
 
 Vue.use(VueRouter)
 
@@ -31,6 +33,16 @@ const routes = [
     name: 'events',
     component: EventsTab
   },
+  {
+    path: '/router',
+    name: 'router',
+    component: RouterTab
+  },
+  {
+    path: '/routes',
+    name: 'routes',
+    component: RoutesTab
+  },
   {
     path: '/perf',
     component: PerfTab,
diff --git a/src/devtools/store/index.js b/src/devtools/store/index.js
index 679131dac..a7956b564 100644
--- a/src/devtools/store/index.js
+++ b/src/devtools/store/index.js
@@ -3,6 +3,8 @@ import Vuex from 'vuex'
 import components from 'views/components/module'
 import vuex from 'views/vuex/module'
 import events from 'views/events/module'
+import router from 'views/router/module'
+import routes from 'views/routes/module'
 import perf from 'views/perf/module'
 
 Vue.use(Vuex)
@@ -27,6 +29,8 @@ const store = new Vuex.Store({
     components,
     vuex,
     events,
+    router,
+    routes,
     perf
   }
 })
@@ -38,6 +42,8 @@ if (module.hot) {
     'views/components/module',
     'views/vuex/module',
     'views/events/module',
+    'views/router/module',
+    'views/routes/module',
     'views/perf/module'
   ], () => {
     try {
@@ -46,6 +52,8 @@ if (module.hot) {
           components: require('views/components/module').default,
           vuex: require('views/vuex/module').default,
           events: require('views/events/module').default,
+          router: require('views/router/module').default,
+          routes: require('views/routes/module').default,
           perf: require('views/perf/module').default
         }
       })
diff --git a/src/devtools/style/variables.styl b/src/devtools/style/variables.styl
index 8e22d9bd3..e246a95c2 100644
--- a/src/devtools/style/variables.styl
+++ b/src/devtools/style/variables.styl
@@ -32,3 +32,47 @@ $dark-border-color = lighten($vue-ui-color-darker, 10%)
 $dark-background-color = $vue-ui-color-darker
 $dark-component-color = $active-color
 $dark-hover-color = $vue-ui-color-dark
+
+// Entries
+// TODO: FIX THIS
+// .no-entries
+//   color: #ccc
+//   text-align: center
+//   margin-top: 50px
+//   line-height: 30px
+//
+// .entry
+//   position: relative;
+//   font-family Menlo, Consolas, monospace
+//   color #881391
+//   cursor pointer
+//   padding 10px 20px
+//   font-size 12px
+//   background-color $background-color
+//   box-shadow 0 1px 5px rgba(0,0,0,.12)
+//   .entry-name
+//     font-weight 600
+//   .entry-source
+//     color #999
+//   .component-name
+//     color $component-color
+//   .entry-type
+//     color #999
+//     margin-left 8px
+//   &.active
+//     color #fff
+//     background-color $active-color
+//     .time, .entry-type, .component-name
+//       color lighten($active-color, 75%)
+//     .entry-name
+//       color: #fff
+//     .entry-source
+//       color #ddd
+//   .app.dark &
+//     background-color $dark-background-color
+//
+// .time
+//   font-size 11px
+//   color #999
+//   float right
+//   margin-top 3px
diff --git a/src/devtools/views/perf/FramerateGraph.vue b/src/devtools/views/perf/FramerateGraph.vue
index 239ef1206..70a0ffa02 100644
--- a/src/devtools/views/perf/FramerateGraph.vue
+++ b/src/devtools/views/perf/FramerateGraph.vue
@@ -76,7 +76,8 @@ import FramerateMarkerInspector from './FramerateMarkerInspector.vue'
 
 const BUBBLE_COLORS = {
   mutations: '#FF6B00',
-  events: '#997fff'
+  events: '#997fff',
+  routes: '#42B983'
 }
 
 // In ms
diff --git a/src/devtools/views/perf/module.js b/src/devtools/views/perf/module.js
index d9f810fa5..51c1f2dc6 100644
--- a/src/devtools/views/perf/module.js
+++ b/src/devtools/views/perf/module.js
@@ -65,6 +65,15 @@ export default {
         }
       }))
 
+      const { routeChanges } = rootState.router
+      addEntries('routes', routeChanges, entry => ({
+        label: entry.to.fullPath,
+        state: {
+          'from': entry.from,
+          'to': entry.to
+        }
+      }))
+
       return markers
     }
   },
diff --git a/src/devtools/views/router/RouterHistory.vue b/src/devtools/views/router/RouterHistory.vue
new file mode 100644
index 000000000..4a9432f65
--- /dev/null
+++ b/src/devtools/views/router/RouterHistory.vue
@@ -0,0 +1,190 @@
+<template>
+  <scroll-pane scroll-event="routes:init">
+    <action-header slot="header">
+      <div class="search">
+        <VueIcon icon="search" />
+        <input
+          ref="filterRoutes"
+          v-model.trim="filter"
+          placeholder="Filter routes"
+        >
+      </div>
+      <a
+        :class="{ disabled: !filteredRoutes.length }"
+        class="button reset"
+        @click="reset"
+      >
+        <VueIcon
+          class="small"
+          icon="do_not_disturb"
+        />
+        <span>Clear</span>
+      </a>
+      <a
+        class="button toggle-recording"
+        @click="toggleRecording"
+      >
+        <VueIcon
+          :class="{ enabled }"
+          class="small"
+          icon="lens"
+        />
+        <span>{{ enabled ? 'Recording' : 'Paused' }}</span>
+      </a>
+    </action-header>
+    <recycle-list
+      slot="scroll"
+      :items="filteredRoutes"
+      :item-height="highDensity ? 22 : 34"
+      class="history"
+      :class="{
+        'high-density': highDensity
+      }"
+    >
+      <div
+        v-if="filteredRoutes.length === 0"
+        slot="after-container"
+        class="no-routes"
+      >
+        No route transitions found<span v-if="!enabled"><br>(Recording is paused)</span>
+      </div>
+
+      <template slot-scope="{ item: route, index, active }">
+        <div
+          class="entry list-item"
+          :class="{ active: inspectedIndex === index }"
+          :data-active="active"
+          @click="inspect(filteredRoutes.indexOf(route))"
+        >
+          <span class="route-name">{{ route.to.path }}</span>
+          <span class="time">{{ route.timestamp | formatTime }}</span>
+          <span
+            v-if="route.to.redirectedFrom"
+            class="label redirect"
+          >
+            redirect
+          </span>
+          <span
+            v-if="isNotEmpty(route.to.name)"
+            class="label name"
+          >
+            {{ route.to.name }}
+          </span>
+        </div>
+      </template>
+    </recycle-list>
+  </scroll-pane>
+</template>
+
+<script>
+import { UNDEFINED } from 'src/util'
+import ScrollPane from 'components/ScrollPane.vue'
+import ActionHeader from 'components/ActionHeader.vue'
+
+import { mapState, mapMutations, mapGetters } from 'vuex'
+
+export default {
+  components: {
+    ScrollPane,
+    ActionHeader
+  },
+  computed: {
+    filter: {
+      get () {
+        return this.$store.state.router.filter
+      },
+      set (filter) {
+        this.$store.commit('router/UPDATE_FILTER', filter)
+      }
+    },
+    highDensity () {
+      const pref = this.$shared.displayDensity
+      return (pref === 'auto' && this.totalCount > 12) || pref === 'high'
+    },
+    ...mapState('router', [
+      'enabled',
+      'routeChanges',
+      'inspectedIndex'
+    ]),
+    ...mapGetters('router', [
+      'filteredRoutes'
+    ])
+  },
+  methods: {
+    ...mapMutations('router', {
+      inspect: 'INSPECT',
+      reset: 'RESET',
+      toggleRecording: 'TOGGLE'
+    }),
+    isNotEmpty (value) {
+      return !!value && value !== UNDEFINED
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.recycle-list
+  height 100%
+
+.no-routes
+  color #ccc
+  text-align center
+  margin-top 50px
+  line-height 30px
+
+.entry
+  font-family Menlo, Consolas, monospace
+  cursor pointer
+  padding 7px 20px
+  font-size 12px
+  line-height: 20px
+  box-shadow inset 0 1px 0px rgba(0, 0, 0, .08)
+  min-height 34px
+  transition padding .15s, min-height .15s
+
+  &::after
+    content: ''
+    display table
+    clear both
+  &.active
+    .time
+      color lighten($active-color, 75%)
+    .action
+      color lighten($active-color, 75%)
+      .vue-ui-icon >>> svg
+        fill  lighten($active-color, 75%)
+      &:hover
+        color lighten($active-color, 95%)
+        .vue-ui-icon >>> svg
+          fill  lighten($active-color, 95%)
+  .high-density &
+    padding 4px 20px
+  span
+    display inline-block
+    vertical-align middle
+
+.route-name
+  font-weight: 600
+
+.time
+  font-size 11px
+  color #999
+  float right
+
+.label
+  float right
+  font-size 10px
+  padding 4px 8px
+  border-radius 6px
+  margin-right 8px
+  margin-top: 1px
+  line-height: 1
+  color: #fff
+  &.name
+    background-color #aaa
+  &.alias
+    background-color #ff8344
+  &.redirect
+    background-color #af90d5
+</style>
diff --git a/src/devtools/views/router/RouterMeta.vue b/src/devtools/views/router/RouterMeta.vue
new file mode 100644
index 000000000..f0a305990
--- /dev/null
+++ b/src/devtools/views/router/RouterMeta.vue
@@ -0,0 +1,91 @@
+<template>
+  <scroll-pane>
+    <div v-if="activeRouteChange" slot="scroll">
+      <state-inspector :state="{ from, to }" />
+    </div>
+    <div v-else slot="scroll" class="no-route-data">
+      No route transition selected
+    </div>
+  </scroll-pane>
+</template>
+
+<script>
+import StateInspector from 'components/StateInspector.vue'
+import ActionHeader from 'components/ActionHeader.vue'
+import ScrollPane from 'components/ScrollPane.vue'
+import { mapGetters } from 'vuex'
+import { UNDEFINED } from 'src/util'
+
+export default {
+  components: {
+    ScrollPane,
+    ActionHeader,
+    StateInspector
+  },
+  computed: {
+    ...mapGetters('router', [
+      'activeRouteChange'
+    ]),
+    to () {
+      return this.sanitizeRouteData(this.activeRouteChange.to)
+    },
+    from () {
+      return this.sanitizeRouteData(this.activeRouteChange.from)
+    }
+  },
+  methods: {
+    sanitizeRouteData (routeData) {
+      const data = {
+        path: routeData.path,
+        fullPath: routeData.fullPath
+      }
+      if (routeData.redirectedFrom) {
+        data.redirectedFrom = routeData.redirectedFrom
+      }
+      if (!this.isEmptyObject(routeData.params)) {
+        data.params = routeData.params
+      }
+      if (!this.isEmptyObject(routeData.query)) {
+        data.query = routeData.query
+      }
+      if (routeData.name && routeData.name !== UNDEFINED) {
+        data.name = routeData.name
+      }
+      if (routeData.hash && routeData.hash !== '') {
+        data.hash = routeData.hash
+      }
+      if (routeData.meta && !this.isEmptyObject(routeData.meta)) {
+        data.meta = routeData.meta
+      }
+      if (routeData.matched && routeData.matched.length > 0) {
+        data.matched = this.sanitizeMatched(routeData.matched)
+      }
+      return data
+    },
+    isEmptyObject (obj) {
+      return Object.keys(obj).length === 0
+    },
+    sanitizeMatched (matched) {
+      const result = []
+      for (let i = 0; i < matched.length; i++) {
+        const obj = {
+          path: matched[i].path
+        }
+        if (matched[i].props && !this.isEmptyObject(matched[i].props)) {
+          obj.props = matched[i].props
+        }
+        result.push(obj)
+      }
+      return result
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.no-route-data
+  color: #ccc
+  text-align: center
+  margin-top: 50px
+  line-height: 30px
+</style>
diff --git a/src/devtools/views/router/RouterTab.vue b/src/devtools/views/router/RouterTab.vue
new file mode 100644
index 000000000..6e5f71ac7
--- /dev/null
+++ b/src/devtools/views/router/RouterTab.vue
@@ -0,0 +1,31 @@
+<template>
+  <div>
+    <split-pane v-if="hasRouter">
+      <router-history slot="left"></router-history>
+      <router-meta slot="right"></router-meta>
+    </split-pane>
+    <div v-else class="notice">
+      <div>
+        No router detected.
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import SplitPane from 'components/SplitPane.vue'
+import RouterHistory from './RouterHistory.vue'
+import RouterMeta from './RouterMeta.vue'
+import { mapState } from 'vuex'
+
+export default {
+  computed: mapState('router', {
+    hasRouter: state => state.hasRouter
+  }),
+  components: {
+    SplitPane,
+    RouterHistory,
+    RouterMeta
+  }
+}
+</script>
diff --git a/src/devtools/views/router/module.js b/src/devtools/views/router/module.js
new file mode 100644
index 000000000..fb34265c2
--- /dev/null
+++ b/src/devtools/views/router/module.js
@@ -0,0 +1,61 @@
+import storage from '../../storage'
+
+const ENABLED_KEY = 'EVENTS_ENABLED'
+const enabled = storage.get(ENABLED_KEY)
+
+const state = {
+  enabled: enabled == null ? true : enabled,
+  hasRouter: false,
+  instances: [],
+  routeChanges: [],
+  inspectedIndex: -1,
+  filter: ''
+}
+
+const mutations = {
+  'INIT' (state, payload) {
+    state.instances = []
+    state.routeChanges = [payload.current]
+    state.inspectedIndex = -1
+    state.hasRouter = true
+    state.instances.push(payload)
+  },
+  'RESET' (state) {
+    state.routeChanges = []
+    state.inspectedIndex = -1
+  },
+  'CHANGED' (state, payload) {
+    state.routeChanges.push(payload)
+    if (!state.filter) {
+      state.inspectedIndex = state.routeChanges.length - 1
+    }
+  },
+  'INSPECT' (state, index) {
+    state.inspectedIndex = index
+  },
+  'UPDATE_FILTER' (state, filter) {
+    state.filter = filter
+  },
+  'TOGGLE' (state) {
+    storage.set(ENABLED_KEY, state.enabled = !state.enabled)
+    bridge.send('router:toggle-recording', state.enabled)
+  }
+}
+
+const getters = {
+  activeRouteChange: state => {
+    return state.routeChanges[state.inspectedIndex]
+  },
+  filteredRoutes: state => {
+    return state.routeChanges.filter(routeChange => {
+      return routeChange.from.fullPath.indexOf(state.filter) > -1 || routeChange.to.fullPath.indexOf(state.filter) > -1
+    })
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  getters
+}
diff --git a/src/devtools/views/routes/RouteMeta.vue b/src/devtools/views/routes/RouteMeta.vue
new file mode 100644
index 000000000..b9b35fca1
--- /dev/null
+++ b/src/devtools/views/routes/RouteMeta.vue
@@ -0,0 +1,98 @@
+<template>
+  <scroll-pane>
+    <div
+      v-if="activeRouteChange"
+      slot="scroll"
+    >
+      <state-inspector :state="{ from, to }" />
+    </div>
+    <div
+      v-else
+      slot="scroll"
+      class="no-route-data"
+    >
+      No route transition selected
+    </div>
+  </scroll-pane>
+</template>
+
+<script>
+import StateInspector from 'components/StateInspector.vue'
+import ActionHeader from 'components/ActionHeader.vue'
+import ScrollPane from 'components/ScrollPane.vue'
+import { mapGetters } from 'vuex'
+import { UNDEFINED } from 'src/util'
+
+export default {
+  components: {
+    ScrollPane,
+    ActionHeader,
+    StateInspector
+  },
+  computed: {
+    ...mapGetters('router', [
+      'activeRouteChange'
+    ]),
+    to () {
+      return this.sanitizeRouteData(this.activeRouteChange.to)
+    },
+    from () {
+      return this.sanitizeRouteData(this.activeRouteChange.from)
+    }
+  },
+  methods: {
+    sanitizeRouteData (routeData) {
+      const data = {
+        path: routeData.path,
+        fullPath: routeData.fullPath
+      }
+      if (routeData.redirectedFrom) {
+        data.redirectedFrom = routeData.redirectedFrom
+      }
+      if (!this.isEmptyObject(routeData.params)) {
+        data.params = routeData.params
+      }
+      if (!this.isEmptyObject(routeData.query)) {
+        data.query = routeData.query
+      }
+      if (routeData.name && routeData.name !== UNDEFINED) {
+        data.name = routeData.name
+      }
+      if (routeData.hash && routeData.hash !== '') {
+        data.hash = routeData.hash
+      }
+      if (routeData.meta && !this.isEmptyObject(routeData.meta)) {
+        data.meta = routeData.meta
+      }
+      if (routeData.matched && routeData.matched.length > 0) {
+        data.matched = this.sanitizeMatched(routeData.matched)
+      }
+      return data
+    },
+    isEmptyObject (obj) {
+      return Object.keys(obj).length === 0
+    },
+    sanitizeMatched (matched) {
+      const result = []
+      for (let i = 0; i < matched.length; i++) {
+        const obj = {
+          path: matched[i].path
+        }
+        if (matched[i].props && !this.isEmptyObject(matched[i].props)) {
+          obj.props = matched[i].props
+        }
+        result.push(obj)
+      }
+      return result
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.no-route-data
+  color: #ccc
+  text-align: center
+  margin-top: 50px
+  line-height: 30px
+</style>
diff --git a/src/devtools/views/routes/RouterTab.vue b/src/devtools/views/routes/RouterTab.vue
new file mode 100644
index 000000000..a7aad9ada
--- /dev/null
+++ b/src/devtools/views/routes/RouterTab.vue
@@ -0,0 +1,39 @@
+<template>
+  <div>
+    <triple-pane v-if="hasRouter">
+      <routes-tree slot="left" />
+      <routes-history slot="middle" />
+      <routes-meta slot="right" />
+    </triple-pane>
+    <div
+      v-else
+      class="notice"
+    >
+      <div>
+        No routes detected.
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import SplitPane from 'components/SplitPane.vue'
+import TriplePane from 'components/TriplePane.vue'
+import RoutesHistory from './RoutesHistory.vue'
+import RoutesTree from './RoutesTree.vue'
+import RoutesMeta from './RoutesMeta.vue'
+import { mapState } from 'vuex'
+
+export default {
+  components: {
+    SplitPane,
+    TriplePane,
+    RoutesHistory,
+    RoutesMeta,
+    RoutesTree
+  },
+  computed: mapState('routes', {
+    hasRouter: state => state.hasRouter
+  })
+}
+</script>
diff --git a/src/devtools/views/routes/RoutesHistory.vue b/src/devtools/views/routes/RoutesHistory.vue
new file mode 100644
index 000000000..4a9432f65
--- /dev/null
+++ b/src/devtools/views/routes/RoutesHistory.vue
@@ -0,0 +1,190 @@
+<template>
+  <scroll-pane scroll-event="routes:init">
+    <action-header slot="header">
+      <div class="search">
+        <VueIcon icon="search" />
+        <input
+          ref="filterRoutes"
+          v-model.trim="filter"
+          placeholder="Filter routes"
+        >
+      </div>
+      <a
+        :class="{ disabled: !filteredRoutes.length }"
+        class="button reset"
+        @click="reset"
+      >
+        <VueIcon
+          class="small"
+          icon="do_not_disturb"
+        />
+        <span>Clear</span>
+      </a>
+      <a
+        class="button toggle-recording"
+        @click="toggleRecording"
+      >
+        <VueIcon
+          :class="{ enabled }"
+          class="small"
+          icon="lens"
+        />
+        <span>{{ enabled ? 'Recording' : 'Paused' }}</span>
+      </a>
+    </action-header>
+    <recycle-list
+      slot="scroll"
+      :items="filteredRoutes"
+      :item-height="highDensity ? 22 : 34"
+      class="history"
+      :class="{
+        'high-density': highDensity
+      }"
+    >
+      <div
+        v-if="filteredRoutes.length === 0"
+        slot="after-container"
+        class="no-routes"
+      >
+        No route transitions found<span v-if="!enabled"><br>(Recording is paused)</span>
+      </div>
+
+      <template slot-scope="{ item: route, index, active }">
+        <div
+          class="entry list-item"
+          :class="{ active: inspectedIndex === index }"
+          :data-active="active"
+          @click="inspect(filteredRoutes.indexOf(route))"
+        >
+          <span class="route-name">{{ route.to.path }}</span>
+          <span class="time">{{ route.timestamp | formatTime }}</span>
+          <span
+            v-if="route.to.redirectedFrom"
+            class="label redirect"
+          >
+            redirect
+          </span>
+          <span
+            v-if="isNotEmpty(route.to.name)"
+            class="label name"
+          >
+            {{ route.to.name }}
+          </span>
+        </div>
+      </template>
+    </recycle-list>
+  </scroll-pane>
+</template>
+
+<script>
+import { UNDEFINED } from 'src/util'
+import ScrollPane from 'components/ScrollPane.vue'
+import ActionHeader from 'components/ActionHeader.vue'
+
+import { mapState, mapMutations, mapGetters } from 'vuex'
+
+export default {
+  components: {
+    ScrollPane,
+    ActionHeader
+  },
+  computed: {
+    filter: {
+      get () {
+        return this.$store.state.router.filter
+      },
+      set (filter) {
+        this.$store.commit('router/UPDATE_FILTER', filter)
+      }
+    },
+    highDensity () {
+      const pref = this.$shared.displayDensity
+      return (pref === 'auto' && this.totalCount > 12) || pref === 'high'
+    },
+    ...mapState('router', [
+      'enabled',
+      'routeChanges',
+      'inspectedIndex'
+    ]),
+    ...mapGetters('router', [
+      'filteredRoutes'
+    ])
+  },
+  methods: {
+    ...mapMutations('router', {
+      inspect: 'INSPECT',
+      reset: 'RESET',
+      toggleRecording: 'TOGGLE'
+    }),
+    isNotEmpty (value) {
+      return !!value && value !== UNDEFINED
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.recycle-list
+  height 100%
+
+.no-routes
+  color #ccc
+  text-align center
+  margin-top 50px
+  line-height 30px
+
+.entry
+  font-family Menlo, Consolas, monospace
+  cursor pointer
+  padding 7px 20px
+  font-size 12px
+  line-height: 20px
+  box-shadow inset 0 1px 0px rgba(0, 0, 0, .08)
+  min-height 34px
+  transition padding .15s, min-height .15s
+
+  &::after
+    content: ''
+    display table
+    clear both
+  &.active
+    .time
+      color lighten($active-color, 75%)
+    .action
+      color lighten($active-color, 75%)
+      .vue-ui-icon >>> svg
+        fill  lighten($active-color, 75%)
+      &:hover
+        color lighten($active-color, 95%)
+        .vue-ui-icon >>> svg
+          fill  lighten($active-color, 95%)
+  .high-density &
+    padding 4px 20px
+  span
+    display inline-block
+    vertical-align middle
+
+.route-name
+  font-weight: 600
+
+.time
+  font-size 11px
+  color #999
+  float right
+
+.label
+  float right
+  font-size 10px
+  padding 4px 8px
+  border-radius 6px
+  margin-right 8px
+  margin-top: 1px
+  line-height: 1
+  color: #fff
+  &.name
+    background-color #aaa
+  &.alias
+    background-color #ff8344
+  &.redirect
+    background-color #af90d5
+</style>
diff --git a/src/devtools/views/routes/RoutesMeta.vue b/src/devtools/views/routes/RoutesMeta.vue
new file mode 100644
index 000000000..92b7ef120
--- /dev/null
+++ b/src/devtools/views/routes/RoutesMeta.vue
@@ -0,0 +1,100 @@
+<template>
+  <scroll-pane>
+    <div
+      v-if="activeRouteChange"
+      slot="scroll"
+    >
+      <state-inspector :state="{ options }" />
+    </div>
+    <div
+      v-else
+      slot="scroll"
+      class="no-route-data"
+    >
+      No route selected
+    </div>
+  </scroll-pane>
+</template>
+
+<script>
+import StateInspector from 'components/StateInspector.vue'
+import ActionHeader from 'components/ActionHeader.vue'
+import ScrollPane from 'components/ScrollPane.vue'
+import { mapGetters } from 'vuex'
+import { UNDEFINED } from 'src/util'
+
+export default {
+  components: {
+    ScrollPane,
+    ActionHeader,
+    StateInspector
+  },
+  computed: {
+    ...mapGetters('routes', [
+      'activeRouteChange'
+    ]),
+    options () {
+      return this.sanitizeRouteData(this.activeRouteChange)
+    },
+    to () {
+      return this.sanitizeRouteData(this.activeRouteChange.to)
+    },
+    from () {
+      return this.sanitizeRouteData(this.activeRouteChange.from)
+    }
+  },
+  methods: {
+    sanitizeRouteData (routeData) {
+      console.log(routeData)
+      const data = {
+        path: routeData.path
+      }
+      if (routeData.redirect) {
+        data.redirect = routeData.redirect
+      }
+      if (routeData.alias) {
+        data.alias = routeData.alias
+      }
+      if (!this.isEmptyObject(routeData.props)) {
+        data.props = routeData.props
+      }
+      if (routeData.name && routeData.name !== UNDEFINED) {
+        data.name = routeData.name
+      }
+      if (routeData.component) {
+        const component = {}
+        // if (routeData.component.__file) {
+        //   component.file = routeData.component.__file
+        // }
+        if (routeData.component.template) {
+          component.template = routeData.component.template
+        }
+        if (routeData.component.props) {
+          component.props = routeData.component.props
+        }
+        if (!this.isEmptyObject(component)) {
+          data.component = component
+        }
+      }
+      if (routeData.children) {
+        data.children = []
+        routeData.children.forEach((item) => {
+          data.children.push(this.sanitizeRouteData(item))
+        })
+      }
+      return data
+    },
+    isEmptyObject (obj) {
+      return obj === UNDEFINED || !obj || Object.keys(obj).length === 0
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.no-route-data
+  color: #ccc
+  text-align: center
+  margin-top: 50px
+  line-height: 30px
+</style>
diff --git a/src/devtools/views/routes/RoutesTab.vue b/src/devtools/views/routes/RoutesTab.vue
new file mode 100644
index 000000000..b4beb9fe5
--- /dev/null
+++ b/src/devtools/views/routes/RoutesTab.vue
@@ -0,0 +1,31 @@
+<template>
+  <div>
+    <split-pane v-if="hasRouter">
+      <routes-tree slot="left"></routes-tree>
+      <routes-meta slot="right"></routes-meta>
+    </split-pane>
+    <div v-else class="notice">
+      <div>
+        No routes detected.
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import SplitPane from 'components/SplitPane.vue'
+import RoutesTree from './RoutesTree.vue'
+import RoutesMeta from './RoutesMeta.vue'
+import { mapState } from 'vuex'
+
+export default {
+  computed: mapState('routes', {
+    hasRouter: state => state.hasRouter
+  }),
+  components: {
+    SplitPane,
+    RoutesMeta,
+    RoutesTree
+  }
+}
+</script>
diff --git a/src/devtools/views/routes/RoutesTree.vue b/src/devtools/views/routes/RoutesTree.vue
new file mode 100644
index 000000000..061cb7907
--- /dev/null
+++ b/src/devtools/views/routes/RoutesTree.vue
@@ -0,0 +1,60 @@
+<template>
+  <scroll-pane scroll-event="routes:init">
+    <action-header slot="header">
+      <div class="search">
+        <VueIcon icon="search" />
+        <input
+          ref="filterRoutes"
+          v-model.trim="filter"
+          placeholder="Filter routes"
+        >
+      </div>
+    </action-header>
+    <div slot="scroll" class="tree">
+      <routes-tree-item
+        v-for="(route, index) in filteredRoutes"
+        ref="instances"
+        :key="route.path"
+        :route="route"
+        :routeId="index"
+        :depth="0">
+      </routes-tree-item>
+    </div>
+  </scroll-pane>
+</template>
+
+<script>
+import ScrollPane from 'components/ScrollPane.vue'
+import ActionHeader from 'components/ActionHeader.vue'
+import RoutesTreeItem from './RoutesTreeItem.vue'
+
+import { mapGetters } from 'vuex'
+
+export default {
+  components: {
+    ScrollPane,
+    ActionHeader,
+    RoutesTreeItem
+  },
+  computed: {
+    filter: {
+      get () {
+        return this.$store.state.routes.filter
+      },
+      set (filter) {
+        this.$store.commit('routes/UPDATE_FILTER', filter)
+      }
+    },
+    ...mapGetters('routes', [
+      'filteredRoutes'
+    ])
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.route-heading
+  padding: 0px 10px
+.tree
+  padding 5px
+</style>
diff --git a/src/devtools/views/routes/RoutesTreeItem.vue b/src/devtools/views/routes/RoutesTreeItem.vue
new file mode 100644
index 000000000..1996149ab
--- /dev/null
+++ b/src/devtools/views/routes/RoutesTreeItem.vue
@@ -0,0 +1,186 @@
+<template>
+  <div
+    class="instance"
+    :class="{ selected: selected }"
+  >
+    <div
+      class="self"
+      :class="{ selected: selected }"
+      :style="{ paddingLeft: depth * 15 + 'px' }"
+      @click.stop="inspect(routeId)"
+      @dblclick="toggleExpand"
+    >
+      <span class="content">
+        <!-- arrow wrapper for better hit box -->
+        <span
+          v-if="route.children && route.children.length"
+          class="arrow-wrapper"
+          @click="toggleExpand"
+        >
+          <span
+            class="arrow right"
+            :class="{ rotated: expanded }"
+          />
+        </span>
+        <span class="instance-name">
+          {{ route.path }}
+        </span>
+      </span>
+      <span
+        v-if="route.name"
+        class="info name"
+      >
+        {{ route.name }}
+      </span>
+      <span
+        v-if="route.alias"
+        class="info alias"
+      >
+        alias: <b>{{ route.alias }}</b>
+      </span>
+      <span
+        v-if="route.redirect"
+        class="info redirect"
+      >
+        redirect: <b>{{ route.redirect }}</b>
+      </span>
+      <span
+        v-if="isActive"
+        class="info active"
+      >
+        active
+      </span>
+    </div>
+    <div v-if="expanded">
+      <routes-tree-item
+        v-for="(child, key) in route.children"
+        :key="child.path"
+        :route="child"
+        :route-id="routeId + '_' + key"
+        :depth="depth + 1"
+      />
+    </div>
+  </div>
+
+</template>
+
+<script>
+import { mapState, mapMutations, mapGetters } from 'vuex'
+
+export default {
+  name: 'RoutesTreeItem',
+  props: {
+    routeId: {
+      type: [String, Number],
+      required: true
+    },
+    route: {
+      type: Object,
+      required: true
+    },
+    depth: {
+      type: Number,
+      required: true
+    }
+  },
+  data () {
+    return {
+      expanded: false
+    }
+  },
+  computed: {
+    ...mapState('routes', [
+      'inspectedIndex'
+    ]),
+    ...mapGetters('routes', [
+      'activeRoute'
+    ]),
+    selected () {
+      return this.inspectedIndex === this.routeId
+    },
+    isActive () {
+      return this.activeRoute && this.activeRoute.path === this.route.path
+    }
+  },
+  methods: {
+    ...mapMutations('routes', {
+      inspect: 'INSPECT'
+    }),
+    toggleExpand () {
+      this.expanded = !this.expanded
+    }
+  }
+}
+</script>
+
+<style lang="stylus" scoped>
+.instance
+  font-family Menlo, Consolas, monospace
+
+.self
+  cursor pointer
+  position relative
+  overflow hidden
+  z-index 2
+  background-color $background-color
+  transition background-color .1s ease
+  border-radius 3px
+  font-size 14px
+  line-height 22px
+  height 22px
+  white-space nowrap
+  &.selected
+    background-color $active-color
+    .arrow
+      border-left-color #fff
+    .instance-name
+      color #fff
+
+.arrow
+  position absolute
+  top 5px
+  left 4px
+  transition transform .1s ease, border-left-color .1s ease
+  &.rotated
+    transform rotate(90deg)
+
+.arrow-wrapper
+  position absolute
+  display inline-block
+  width 16px
+  height 16px
+  top 0
+  left 4px
+
+.children
+  position relative
+  z-index 1
+
+.content
+  position relative
+  padding-left 22px
+
+.instance-name
+  color $component-color
+  margin 0 1px
+  transition color .1s ease
+
+.info
+  color #fff
+  font-size 10px
+  padding 3px 5px 2px
+  display inline-block
+  line-height 10px
+  border-radius 3px
+  position relative
+  top -1px
+  margin-left 6px
+  &.name
+    background-color #b3cbf7
+  &.alias
+    background-color #ff8344
+  &.redirect
+    background-color #aaa
+  &.active
+    background-color: #2c7d59
+</style>
diff --git a/src/devtools/views/routes/module.js b/src/devtools/views/routes/module.js
new file mode 100644
index 000000000..cb1c1710b
--- /dev/null
+++ b/src/devtools/views/routes/module.js
@@ -0,0 +1,62 @@
+import storage from '../../storage'
+
+const ENABLED_KEY = 'EVENTS_ENABLED'
+const enabled = storage.get(ENABLED_KEY)
+
+const state = {
+  enabled: enabled == null ? true : enabled,
+  hasRouter: false,
+  routeChanges: [],
+  inspectedIndex: -1,
+  filter: ''
+}
+
+const mutations = {
+  INIT (state, payload) {
+    state.inspectedIndex = -1
+    state.hasRouter = true
+    state.routeChanges = payload.routeChanges
+  },
+  CHANGED (state, payload) {
+    state.routeChanges.push(payload)
+  },
+  INSPECT (state, index) {
+    state.inspectedIndex = index
+  },
+  UPDATE_FILTER (state, filter) {
+    state.filter = filter
+  }
+}
+
+const getters = {
+  activeRouteChange: state => {
+    if (typeof state.inspectedIndex === 'string') {
+      const path = state.inspectedIndex.split('_')
+      let obj = state.routeChanges[parseInt(path[0])]
+      for (var i = 1, len = path.length; i < len; ++i) {
+        obj = obj.children[parseInt(path[i])]
+      }
+      return obj
+    }
+    return state.routeChanges[state.inspectedIndex]
+  },
+  activeRoute: (state, getters, rootState) => {
+    return state.routeChanges.find(
+      change => rootState.router.routeChanges.find(
+        historyChange => historyChange.to.path === change.path
+      )
+    )
+  },
+  filteredRoutes: state => {
+    return state.routeChanges.filter(routeChange => {
+      return routeChange.path.indexOf(state.filter) > -1
+    })
+  }
+}
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  getters
+}