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 thanbeforeEnter
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. dispatchingbeforeRouteEnter
,beforeRouteUpdate
,beforeRouteLeave
and handling thenext
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 theurl
(routing) module in both directions. This coupling makes both modules less flexible and more complex.