Dynamic routes and params
Match many addresses with one route, read the param as a prop, reload data when it changes, navigate from code, keep searches in the query string and catch unknown paths with a 404 page.
What you'll learn
When you finish, you can do these.- 1Match every user's address with one route and a param
- 2Get the param as a prop and turn it into a number
- 3Load the new user when only the id in the address changes
- 4Keep a search in the query string and add a 404 page
A team app has a page for every person: Asha's at the address /users/1, Ben's at /users/2, Chen's at /users/3. The pages differ only in whose name and city they show. Each page needs a route, the rule that sends one address to its page, and one route per person would mean a new route every time someone joins. A dynamic route matches the whole family of addresses with one pattern, and tells the page which person was asked for.
The Code tab holds that team app: a list page with a search box (pages/UserList.vue) and one page that shows any user (pages/UserPage.vue). By the end you can say how one route serves them all, and you add a Back button that keeps the search. You need one idea from Your first router, the first lesson of this track: Vue Router, Vue's library for pages, looks through its list of routes for the one whose path matches the address, and shows that route's page.
One route for every user
One route with a placeholder in it serves every user's page. An address is made of segments, the pieces of text between slashes. In a route's path, the address pattern it matches, a segment that starts with a colon is a param: a placeholder that matches any value in that position. /users/:id matches /users/1 and /users/ben, but not /users or /users/1/edit. The router stores the matched value under the param's name, id.
- Address/users/42
- Route/users/:id
- Paramparams.id is '42'
- PropUserPage gets id
In router.ts, the user route is one line:
{ path: '/users/:id', name: 'user', component: UserPage, props: true },
With this route, /users/2 shows UserPage, the page for one user. useRoute() returns the current route, so any component can read the param as route.params.id, and a template can use $route.
props: true also hands the param to UserPage.vue as a prop, the way a parent component passes a value to a child, and the page declares id with defineProps().
The page's load() function asks loadUser(), a fake server in api.ts that answers after 300 ms, for that user, and sets loading while it waits:
const props = defineProps<{ id: string }>()
async function load(id: string) {
loading.value = true
user.value = await loadUser(Number(id))
loading.value = false
}
Click Asha in the list: Loading… shows for a moment, then Asha and Lives in Leeds.
The type says string on purpose: an address is text, so /users/2 gives '2', never the number 2. The ids in api.ts are numbers, so load() converts with Number() first.
The same page, a new id
Moving from one user to the next keeps the same page on screen, so it has to notice when only its id changes.
The Code tab's preview has no address bar, so App.vue prints the current address, $route.fullPath, at its top: the address line.
Click Next user, a link at the bottom of Asha's page, and the address line becomes /users/2. The same route still matches, so Vue Router keeps the UserPage on screen and only changes its id prop. onMounted(), which runs code once when a component first appears, does not run again.
A page that loaded its user in onMounted() would keep showing Asha, so UserPage.vue loads in a watcher. watch() runs code when a value changes: here it reads the prop with () => props.id and calls load with the new id whenever it changes. { immediate: true } also runs load on the first visit.
watch(() => props.id, load, { immediate: true })
Click Next user now: Loading… shows for a moment, then Ben and Lives in Lyon.
Links from code, the query string and a 404 page
Three more pieces finish the app: navigating from code, a search kept in the address, and a 404 page, which says Page not found for any unknown address.
useRouter() returns the router itself, for changing the page from code. The list page's Open user 3 from code button calls router.push({ name: 'user', params: { id: 3 } }), which picks the route named user and fills in its param.
Pass params only with a name. Next to a path, as in { path: '/users', params: { id: 3 } }, Vue Router drops them and warns in development.
push adds an entry to the browser's history, so Back returns to where you were. router.replace() swaps the current entry instead, and router.back() steps back. In a template, $router is the same router.
The query string is the part of the address after ?, as in /?q=be, and a script reads it from route.query. It suits a choice that changes how a page looks but not which page it is, such as a search, and it survives a refresh, Back and a shared link.
The list page keeps its search box's text in ?q=. search reads it, and onSearch writes each keystroke back with replace, so Back does not step through every letter:
const search = computed(() => String(route.query.q ?? ''))
function onSearch(event: Event) {
const q = (event.target as HTMLInputElement).value
router.replace({ query: q ? { q } : {} })
}
Type be in the box: the list shows only Ben, and the address line reads /?q=be.
Last, in router.ts, one route catches every address no other route matches:
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
Its path is one param, pathMatch: (.*) lets it match anything, slashes included, and the final * lets it span several segments. The router ranks routes by how specific they are, so this one wins only when nothing else matches. Click A link to nowhere on the list page: the address line reads /teams/42, and the page says Page not found.
What goes wrong
Both mistakes show the wrong thing, with no warning.
The first passes the param to loadUser() as it came from the address:
user.value = await loadUser(id) // id is '2', a string
loadUser compares u.id === id, the number 2 against the string '2', which is false for every user, so the page says No user with id 2. Convert first:
user.value = await loadUser(Number(id))
The second loads the user only when the page mounts:
onMounted(() => load(props.id))
Open Asha and click Next user. The address line says /users/2, but the page still says Asha, because the reused page never runs onMounted() again. Watch the prop instead:
watch(() => props.id, load, { immediate: true })
What to remember
:idin a path matches any value in that position. The value lands inroute.params.id, or in anidprop withprops: true.- Params and query values are strings. Convert with
Number()before comparing them with numbers. - Moving between addresses of the same route reuses the page. Watch the param with
immediate: trueinstead of loading only inonMounted(). router.push()adds a history entry androuter.replace()swaps the current one. Passparamswith aname, never with apath.- Keep searches, filters and tabs in the query string, and catch unknown addresses with a
/:pathMatch(.*)*route.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add a Back button that keeps the search. In
UserPage.vue, add<button @click="$router.back()">Back</button>next to the links. Typebein the search, open Ben, then click Back: the list is still filtered to Ben, and the address line reads /?q=be. - Put tabs in the query string. In
UserPage.vue, add<RouterLink :to="{ query: { tab: 'posts' } }">Posts</RouterLink>, the same forlikes, and<p>Showing {{ $route.query.tab ?? 'posts' }}</p>. Open Asha and click Likes: the address line reads /users/1?tab=likes and the new line says Showing likes, with no Loading…, becauseiddid not change. - Break it two ways. Replace the watcher with
onMounted(() => load(props.id)), addingonMountedto the import fromvue: open Asha, click Next user, and the page still shows Asha under /users/2. Put the watcher back, then delete the catch-all route and click A link to nowhere: nothing renders under the address line, and the preview shows[VUE_ROUTER_R0004] No match found for location with path "/teams/42".