Dynamic Routing with Route Params in Vue 3

masonmedia
16 min readApr 3, 2023

--

The basics of dynamic routing in Vue are relatively straightforward, but the documentation, available examples, and popular tutorials are a bit hard to make sense of, especially when it comes to dynamic routing with dynamic data (i.e. data coming from an API). There are also differences from Vue 2 as well as a raft of small problems I ran into that took time to figure out. Here’s what I found.

Photo by Jan Huber on Unsplash

Intro

For this article we’ll use a blog as an example. We’ll create one page/view to list all the posts, and another page to be the dynamic “Detail” template that displays the content of each individual post. The post data will come from two examples, a single local JSON file (an array of objects) and an API with the same data. We’ll use Vue 3 and the Composition API, computed properties to find and match route params with the data, and the fetch() method for GETting our same data from an API.

Static routing

It’s not technically called “static”, but the usual mode of navigating between pages (routes) in a Vue application that are individually hard coded is an example of static routing. The name, path, and any other attributes of a route stay the same. When you need a new page, you create a new view, and open up router/index.js, and type out a new route path.

Dynamic routing essentials

Dynamic routing is when your application routes (or part of your app) get created on request by/from a data source: in most cases either a local JSON file or dynamic data from an API. Dynamic routing is readily exemplified in blogs and CMSs, news and tech applications, and ecommerce sites: where you have a large number of items (posts, products, articles) that can be displayed in the same way.

Dynamic routing removes the need to manually declare your routes (or update your layout) which makes an application vastly more scalable, and manageable by a user or non-tech client.

There are three core parts to it:

  1. Declare a dynamic route in your router/index.js file.
  2. Create a view/page that will render all your data as a list or grid of individual items (think like a page showing all posts or products). Import your data source and create dynamic router-links that will take care of the dynamic navigation to the next step, a dynamic “detail” page.
  3. Create another view/page that will act as a template for the details of each individual item. This page will be populated dynamically. The layout stays more or less the same, but the content is swapped out based on the route parameter.

Data Sources

As mentioned, there are two basic ways to source data: a local file, or a remote API. We’ll start with local JSON data and follow up with how to route dynamically with data from an API.

Local JSON file

First let’s go through the steps of setting up dynamic routing with local data.

You want to structure your data as a JSON array of objects. Each object needs to have a unique key to use as a route parameter. Route params are how the router matches and populates a dynamic route with its corresponding data. Very often each object has a unique id but other keys can be used as well: title, slug, date, etc. The value of this key will become the slug in your url: i.e. blog/1, or blog/my-first-post. Be aware that the best value to use is one word, or a number. Multiple word values should be separated by dashes and not contain special characters (brackets, colons, etc).

  1. Create a JSON file.
    I like to create a data folder in the src directory and put all the JSON into a posts.json file inside it. (I copied the first 9 entries below from json placeholder to use as our static local data. We’ll use the live API for our dynamic data).
// data/posts.json

[
{
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
},
{
"userId": 1,
"id": 2,
"title": "qui est esse",
"body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
},
{
"userId": 1,
"id": 3,
"title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
"body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut"
},
{
"userId": 1,
"id": 4,
"title": "eum et est occaecati",
"body": "ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure\nquis sunt voluptatem rerum illo velit"
},
{
"userId": 1,
"id": 5,
"title": "nesciunt quas odio",
"body": "repudiandae veniam quaerat sunt sed\nalias aut fugiat sit autem sed est\nvoluptatem omnis possimus esse voluptatibus quis\nest aut tenetur dolor neque"
},
{
"userId": 1,
"id": 6,
"title": "dolorem eum magni eos aperiam quia",
"body": "ut aspernatur corporis harum nihil quis provident sequi\nmollitia nobis aliquid molestiae\nperspiciatis et ea nemo ab reprehenderit accusantium quas\nvoluptate dolores velit et doloremque molestiae"
},
{
"userId": 1,
"id": 7,
"title": "magnam facilis autem",
"body": "dolore placeat quibusdam ea quo vitae\nmagni quis enim qui quis quo nemo aut saepe\nquidem repellat excepturi ut quia\nsunt ut sequi eos ea sed quas"
},
{
"userId": 1,
"id": 8,
"title": "dolorem dolore est ipsam",
"body": "dignissimos aperiam dolorem qui eum\nfacilis quibusdam animi sint suscipit qui sint possimus cum\nquaerat magni maiores excepturi\nipsam ut commodi dolor voluptatum modi aut vitae"
},
{
"userId": 1,
"id": 9,
"title": "nesciunt iure omnis dolorem tempora et accusantium",
"body": "consectetur animi nesciunt iure dolore\nenim quia ad\nveniam autem ut quam aut nobis\net est aut quod aut provident voluptas autem voluptas"
}
]

2. Create your new routes: add one for your blog posts /blog (which will route to BlogPosts.vue) and another that will be the dynamic route path for each individual post, /blog/:id. Open up your router/index.jsand add this:

// router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// don't need this, but I added a stock home/welcome page
{
path: '/',
name: 'Home',
component: HomeView
},
// page for all posts
{
path: '/blog',
name: 'Blog',
component: () => import('../views/BlogPosts.vue')
},
// page for each post to be dynamically rendered
{
path: '/blog/:id',
name: 'Detail',
component: () => import('../views/BlogDetail.vue')
},
]
})

export default router

3. Create your new pages.
In the views folder, add a new file, BlogPosts.vue. This will display all your posts. Then create another called BlogDetail.vue. This will display each individual post.

4. Set up BlogPosts.vue: import your data from the local JSON file — posts.json

// views/BlogPosts.vue

<script setup>
import postData from '../data/posts.json'
</script>

<template>
// We'll fill this in a minute
</template>

4. Render all your posts on the page. I’m using v-forto loop over the data and some basic Bootstrap classes to style the posts in a 3 column grid.

// views/BlogPosts.vue

<script setup>
import postData from '../data/posts.json'
</script>

<template>
<div class="container-fluid">
<div class="row">
<div class="col-lg-4" v-for="(post, index) in postData" :key="index">
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
<button>Read more</button>
</div>
</div>
</div>
</template>

5. Set up links to navigate to your individual posts using router-links with dynamic paths.

// views/BlogPosts.vue

<script setup>
import postData from '../data/posts.json'
</script>

<template>
<div class="container-fluid">
<div class="row">
<div class="col-lg-4" v-for="(post, index) in postData" :key="index">
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>

// add a dynamic path to the router-link
<router-link :to="'/blog/' + post.id">
<button>Read more</button>
</router-link>

// you can also use a template literal = same result
<router-link :to="`/blog/${post.id}`">
<button>Read more</button>
</router-link>
</div>
</div>
</div>
</template>

The trickier parts:

Now we have to do the functional work of dynamically matching the parameter you declared in your route (i.e. /blog/:id), with the corresponding individual object in your data. This is how the router will know to insert a new value into the url and then fetch the corresponding data from your JSON file to populate the BlogDetail.vue page template.

As a caveat, I find this part the most confusing and also confused when it comes to routing tutorials and the Vue community. Most of them glaze over or only quickly explain how this works, why it works, and especially, what doesn’t work. And to be honest I don’t know the exact ins and outs but I’ll do my best to explain how I understand it.

Computed properties

From the Vue docs:

“…for complex logic that includes reactive data, it is recommended to use a computed property…You can data-bind to computed properties in templates just like a normal property. Vue is aware that [your computed property function] depends on [a data source or specific value] so it will update any bindings that depend on [your computed property] whenever the data changes.”

For example, a computed property will run a function or some logic on whatever value you declare, and return a result. In our case we want it to filter and/or find the parameter we set in our router, and match it to our JSON data source to return a single object: i.e. a single blog post.

6. In our BlogDetail.vue page, let’s set up a computed property to match our dynamic routes with our data.
In Vue 3 we have to import computed from vue. To use route params we also have to import the useRoute() function from the vue-router.

// BlogDetail.vue

<script setup>
import { computed } from 'vue'; // import computed
import { useRoute } from "vue-router"; // import route
import postData from '../data/posts.json'; // import our data

// declare useRoute() a constant so we can use the route params
// more easily in the template
const route = useRoute();

// declare your computed function => we use the .find() method
// to search our data for an 'id' key that matches the route parameter we set
// in our router/index.js file i.e. /blog/:id

const post = computed(() => {
return postData.find(post => post.id === route.params.id)
})
</script>

So what happens is the computed property runs .find() on the postData. It searches through all the posts for the id key. When you click on any of the posts listed on the main BlogPosts.vue page (all of which come from the postData.json file and all of which have unique IDs), the router navigates to our dynamic template (with an id in the url that matches the id of the post you selected), and the computed function inside the BlogDetail.vue page retrieves the corresponding data from that specific id, and populates it into the template.

For reference we can also use the Javascript .filter()method to match posts and ids.

// The .find() method returns the array element value (if any) 
// of the elements in the array that satisfy a specified condition.
// Since it returns the value itself, it doesn't require a loop to access.

const post = computed(() => {
return postData.find(post => post.id === route.params.id)
})

// The filter() method returns an array with the matched value inside.
// This means we need to loop over the array to access the value.
// If we add the [0] at the end of the function, it specifies we want just the
// first value in the array, then we don't need to loop over it.

const post = computed(() => {
return postData.filter(post => post.id === route.params.id)[0];
})

7. Now we can use our post computed property to render the data from each individual post (again using Bootstrap classes for styling):

// BlogDetail.vue

<script>
import { computed } from 'vue';
import { useRoute } from "vue-router";
import postData from '../data/posts.json';

const route = useRoute();

const post = computed(() => {
return postData.find(post => post.id === route.params.id)
})
</script>

<template>
<div class="container-fluid">
<div class="row">
<div class="col-lg-12">
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</div>
</div>
</div>
</template>

Now you should have a main posts page that shows all your posts in a grid, as well as a functioning detail page that shows the content of each individual post.

Dynamic Routing with Dynamic Data: fetching data from an API

Using an API for your data source in dynamic routing isn’t on paper that much different from local data. That being said, there are small things to take into account that will make it or break it.

We have to walk back to Step 4: importing your data.

Instead of a simple import statement that brings in data from your local JSON file, we need to make an API call to retrieve our data from a remote source. In this case we’ll use the fetch() method to make a GET request to the json placeholder API. We’ll use the /posts endpoint which will return the same data we hard coded into our JSON file above.

First we import ref and onMounted from vue. ref is a reactive object (i.e. state). We set it to be an empty array which will hold our post data after it’s been fetched (it can hold different values, like objects, strings, booleans, and null). More on ref here. It’s the same as when we declare an empty variable in the Options API using the data() option.

*An important note is that to access the value of the object, you have to use .value when referring to the ref: i.e. postData.value will return your posts data whereas postData by itself will be undefined.

// BlogPosts.vue

<script setup>
import { ref, onMounted } from 'vue';

// set an empty reactive object (in this case an array) to hold our post data
const postData = ref([]);

const base_url = "https://jsonplaceholder.typicode.com/posts/";
async function getPosts() {
let response = await fetch(base_url);
postData.value = await response.json();
console.log(postData.value)
}

// execute the getPosts() function in onMounted
onMounted(() => {
getPosts();
})
</script>

So now we’re calling our API and should be getting back an array of objects which fills our dynamic ref variable. Since we’re assigning our data to the same name i.e. postData everything else on this page stays the same:

*Note that like our static JSON data, I’ll use the slice() method inside the v-for to loop over just the first 9 values in the array so we don’t get 100 posts on the page.

// BlogPosts.vue

<script setup>

import { ref, onMounted } from 'vue';

// set an empty reactive object (in this case an array) to hold our post data
const postData = ref([]);

const base_url = "https://jsonplaceholder.typicode.com/posts/";
async function getPosts() {
let response = await fetch(base_url);
postData.value = await response.json();
console.log(postData.value)
}

// execute the getPosts() function in onMounted
onMounted(() => {
getPosts();
})
</script>

// loop over data and render in the template
<template>
<div class="container-fluid">
<div class="row">
<div class="col-lg-4" v-for="(post, index) in postData.slice(0,8)" :key="index">
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>

// add a dynamic path to the router-link
<router-link :to="'/blog/' + post.id">
<button>Read more</button>
</router-link>

// you can also use a template literal = same result
<router-link :to="`/blog/${post.id}`">
<button>Read more</button>
</router-link>
</div>
</div>
</div>
</template>

Rendering individual posts

Here’s the next rub. We can’t use the computed property we ran before on our local data. We need instead to append our route.params to our API endpoint to facilitate navigating dynamically through our data. Since we’re using the id of each post as our route and route parameter, each id creates a new url that renders only the data for that object. The trick is that the url is an actual active url/endpoint on the json placeholder API. If you visit https://jsonplaceholder.typicode.com/posts/1 you get the data only of the first post. If you visit https://jsonplaceholder.typicode.com/posts/ you get all posts.

This is where it gets grey in most tutorials. If you tried to access :slug, for example, you’d get an undefined error because “the resource” doesn’t exist.

If your endpoint is a file (like a .json file in a github repo and published to github pages), or a random generated url from a headless CMS type service, appending an id to the end of the url will not work for dynamic routing. Because that url/resource doesn’t exist. More on this below.

So we need to define another ref, import useRoute() to access route.params, fetch our data again, and this time append the route.params to the endpoint url.

*Another important factoid: the ref in the detail page can’t be another array or else you’ll have to loop over it. Set its value to an object (which it will be) or an empty string.

// BlogDetail.vue

<script setup>

import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';

// access route for route params
const route = useRoute();

// set an empty ref (in this case an object) to hold our individual
// post data
const post = ref({});

// fetch your data with params
const base_url = "https://jsonplaceholder.typicode.com/posts/";
async function getPostDetail() {
let response = await fetch(base_url + route.params.id);
post.value = await response.json();
console.log(post.value)
}

// execute the getPostDetail() function in onMounted
onMounted(() => {
getPostDetail();
})

// The getPostDetail() function can also run immediately and doesn't have
// to be in the onMounted() hook.

</script>

<template>
<div class="container-fluid">
<div class="row">
<div class="col-lg-12">
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</div>
</div>
</div>
</template>

Now when you spool up your server, you should get the same routing functionality as with your local data: a main posts page with all posts, and a detail page with individual post data.

*Update: A note on APIs, resources, and endpoints

As mentioned above there’s a caveat to APIs: I struggled with this for a while before understanding. APIs are built so that there are two basic endpoints: one for ALL resources (i.e. all the data the API offers), and another for EACH resource individually (i.e. every post, every user, every location, etc).

So if you go to https://jsonplaceholder.typicode.com/posts/ you’ll get back an array of ALL posts from the API.

If you go to https://jsonplaceholder.typicode.com/posts/1 you’ll get back a single post entry, with theid of 1. Every post and data type has this format. And this is why dynamic routing will work with a remote data source. If you append an id to the end of the url, a resource exists at that url, so you get your data back.

If you publish your data as a.json file to github pages for example (so it’s consumable as an API enpoint), or use other simple publishing tools to have your data online and consumable, it’s important to understand that the same logic applies (i.e. it won’t work the same way).

First, with the github pages idea, your endpoint will have a .json extension i.e. https://yourusername.github.io/vue_routing_demo/posts/posts.json. Even though this will return all your post data, and even if you set up your dynamic routing in your Vue application, the routing will not work. You can’t create dynamic routes from a single endpoint. If you try you’ll get https://yourusername.github.io/vue_routing_demo/posts/posts.json/1 which will return an error because nothing exists at that url.

I ran through a tutorial that actually used Netlify as a remote API in the same vein as above. But it only worked because the author published each individual resource as a separate .json file. He could then use his route params to dynamically generate routes based on those resources. While it was cool, simple, and easy to set up for a small project, it’s labour intensive and not a viable option for anything remotely bigger than a small website.

Creating a go back button

The Vue router has a built-in function for manipulating browser history i.e. using the browser back and forward buttons. The router.go() function takes an integer as a parameter — this tells the browser how many pages to go back or forward from where you currently are. It’s perfect for a simple go back button:


// go back by one record, the same as router.back()
router.go(-1)

// go forward by one record, the same as router.forward()
router.go(1)

// go forward by 3 records
router.go(3)

//practical use

<template>
<div class="container-fluid">
<div class="row">
<div class="col-lg-12">
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
<button @click="router.go(-1)">Back</button>
</div>
</div>
</div>
</template>

*More on traversing history in the Vue router docs here.

Using other parameters

Route params are just variables that match. You can use (almost) anything as a route parameter. It just has to match the keys in your data: i.e. if you declare a :slug parameter, which is super common (i.e. /post/:slug), you have to have a slug key in your JSON for the computed property to find and match. As mentioned above as well, the key can’t have just any type of data. The parameter becomes the url for that post/item so if you use the title for example (wordpress post urls tend to be the title of the post) and it has spaces and special characters (colons, brackets, commas, etc) you’ll have to have a separate slug key that separates the words with dashes or somehow otherwise returns a useable data value.

Routing directly from one dynamic route to another

You don’t always want to navigate back to a main posts page from a specific article to find more articles. And if you have a smaller e-commerce app with limited products you might want to be able to see all available products and navigate between them at any time, more like a website. In these cases we run into a problem with dynamic routes: vue-router won’t render changes to route parameters. For example, the router won’t know that the current route, blog/post/id1 has changed to blog/post/id4 , even though the url will reflect the change. Here’s where I don’t know all the logic behind it, but I know vue watchers work to alleviate this problem by “reacting” to route param changes.

What did work for me however was a solution from this post using the :key=”” attribute on the <router-view />. Adding $route.fullPathrerenders all dynamic routes with the accurate/updated data:

// App.vue
// This works with the $route in Vue 3 but you can also import the useRoute()
// method to expose a route variable

<router-view :key="$route.fullPath"></router-view>

*But then another problem arose — my page transitions stopped working. The next line down in the same thread suggested putting the $route.fullPath on the component inside the router-view which worked like a charm:

<router-view
v-slot="{ Component }">
<transition
name="slide-fade"
mode="out-in">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>

Resources

Here are a few resources to get more info on the topic:

Vue School offers two great free courses on Vue Router. The frist is Vue Router for Everyone taught by Debbie O’Brien who is a fantastic and accessible teacher. The second is essentially the same course but using Vue Router 4 with Daniel Kelly — it’s quite a bit slower, but also better in that he explains dynamic routing with API data. Both use Vue 2 so you’ll have to migrate elements to Vue 3 yourself.

Vue Router for Everyone
https://vueschool.io/lessons/vue-router-named-routes-and-params

Vue Router 4 for Everyone
https://vueschool.io/lessons/dynamic-routes

https://vueschool.io/lessons/reacting-to-param-changes

BlogRocket has a couple great tutorials on dynamic routing here and here.

Great medium tutorial building a news blog/app with Vue and dynamic routing here.

Vue Router docs for dynamic routing here.

Conclusion

So there you have it. Dynamic routing with local JSON data and with dynamic API data. It was a long rabbit hole of a trip trying to wrap my head around all of this, and I’m still sorting out why some APIs work and others don’t. But the basics are here with, I hope, a pared down explanation that makes some sense. Hopefully this helps you understand and get into the dynamic routing world — it’s super exciting and useful for a lot of applications.

If you like this post, feel free to support my work, and don’t forget to subscribe and check out more posts on my medium home page. Reach out and get more info on my website (built with Vue 3, the medium rss2feed, Bootstrap 5, and GSAP).

Thanks for reading!

--

--

masonmedia

Hi, I’m Andrew. I’m a passionate frontend developer, visual designer, copywriter and musician. Learn more about me at andrewmasonmedia.com.