Skip to content

Instantly share code, notes, and snippets.

@fabianeichinger
Last active December 18, 2023 19:06
Show Gist options
  • Save fabianeichinger/a3e9c75db522cdc5441ce1dc68eba8bb to your computer and use it in GitHub Desktop.
Save fabianeichinger/a3e9c75db522cdc5441ce1dc68eba8bb to your computer and use it in GitHub Desktop.
Matching routes based on entity types with slugs in Vue Router

Vue Router (v3) might provide a very simple way to do slug-based routing where the routing for a given URL depends on the entity referenced by the slug. It has advantages to some of the other solutions around, being quite compact and also compatible with routing guards. As an example, think of an online store with category URLs like example.com/catalog/games and product URLs like example.com/catalog/among-us. The path /catalog/games should match with a route that displays the Category component, while the route for /catalog/among-us should display a Product component. But there is no pattern in the paths for the router to detect. We have to check if either a category or product exists with this slug (and usually do so asynchronously) or show a 404 page in case neither exists.

Below is a very basic implementation that works with some bugs and missing features. I'll explain how it works and what is missing further down. A more feature-complete example can be found here: https://jsfiddle.net/zkpbseL6/7/

async function getTypeForSlug (slug) {
  ... // <0>
  // return 'product' (for slug === 'among-us')
  // return 'category' (for slug === 'category')
  // return null (when no entity of supported type is found for this slug)
}

const router = new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', component: Home },
    { path: '/legal', component: Legal },
    { path: '/catalog/all', component: CompleteCatalog },
    
    { // <1>
    	path: '/catalog/:pathSegments+', 
      async beforeEnter (to, from, next) {
        const slug = to.params.pathSegments.split('/')[0]
        const routeName = await getTypeForSlug(to.params.slug)
        if (routeName) {
          next({ replace: true, name: routeName, params: { slug } }) // <2>
        } else {
          next({ replace: true, name: 'page-not-found', params: { pathSegments: [ 'catalog', slug ] } }) // <2>
        }
    	}
    },
    { name: 'product', path: '/catalog/:slug', component: Product },
    { name: 'category', path: '/catalog/:slug', component: CmsPage },
    { name: 'page-not-found', path: '/:pathSegments+', component: PageNotFound }
  ]
})

This solution uses some details of Vue Router's logic: beforeEnter guards in route definitions, the ability to do async work (during which navigation is pending) and then redirect in beforeEnter guards, top-to-bottom matching of route paths and names.

When navigating to /catalog/among-us, Vue Router would start matching the defined routes top-to-bottom. The path obviously doesn't match the first three definitions but it does match <1>. Notice that the definitions for product and catalog are defined below that. We don't want them to match by path. Both use the same pattern, /catalog/games would try to show a product with slug games, which would most likely lead to an error. Their paths are just defined to allow the router to generate href for links and update the location bar of the browser.

Our router will try to enter the route defined at <1>. It has no component defined, but beforeEnter is called, which may do async work, and is expected to call the next callback (think of next as resolve/reject in a promise). We'll use this to call the code at <0> and await it's result. The implementation may do whatever it needs (query Vuex, Local Storage caches, your server) to return the type of the entity with the given slug.

The beforeEnter function will now know the route we want to enter: product, category, or page-not-found. It calls next({ name, ...}, which tells our router not to enter the route we've matched, but redirect to the route identified by this name. So instead of matching a url path, it will match the route definitions top-to-bottom by name.

Caveats with this basic version above are:

  • Search params and the hash are not passed with the redirect. That's included in the JSFiddle.
  • Slugs for the entities can't contain /. Also possible in the JSFiddle.
  • Routes registered later with router.addRoutes might never be reached if their path overlaps with that of <1>. This is not something I attempted to fix with the JSFiddle, as I'm not sure if there is a good generic solution or if it is project spectific. Vue Router internally has an exception for { route: '*', } so it is automatically pushed to the bottom of the list of routing rules, but that's not something that would work here.
  • Browser history breaks. New states are pushed on back navigation to the <1> rotue.

I comapred this solution in two other ones I found online:

  • Defining a catch-all route like <2>, but giving it a component rather than beforeEnter and letting that component render whatever the "real" route component is. This solution seems to be quite popular on SO. The gotcha here is that the "real" component might expect to be the root component for that route. Emulating that (e.g. dispatching beforeRouteEnter, beforeRouteUpdate, beforeRouteLeave and handling the next callback) is quite tricky and might require additional logic for some projects.
  • Vue Storefront registers an additional urldispatcher route for each product / category it stubles upon (i.e. for each that is fetched directly, as part of a product collection, ...) at runtime. The process there is quite complex and involes dependencies between the (product-)catalog and the url(routing) module in both directions. This coupling makes both modules less flexible and more complex.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment