Skip to content
TutorialRouting

Dynamic routes, params and typed routes

One component, many URLs: /users/7, /users/8, /users/ada. Declare the param, keep the page in step when it changes, type it with useRoute('/users/[id]') and send a missing user to a real 404.

vuejs.school team2 Apr 20266 min readVue Router 5.0

A profile page, a product page and a blog post each use one component for many URLs. Vue Router makes that work with params: named pieces of the path, like the 7 in /users/7, that it extracts for you. Declaring them is the easy part. The trouble starts with a page that keeps showing the old user when only the param changes, and with a URL like /users/999 that matches the route although no such user exists. You will build a user page that handles both, then let TypeScript check its route names and params.

The examples use a Vite project with Vue Router 5.0 (5.0.4 is the current patch) and the code in src/. Vue Router 5 has no breaking changes for apps that write their own routes array, so the examples that use one also run on 4.x. Only the typed routes section needs 5.

Declare params in the path

A colon in a path marks a param. Everything else about params is a small addition to that colon:

src/router.ts
import { createRouter, createWebHistory } from 'vue-router'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    // /users/7 and /users/ada
    { path: '/users/:id', name: 'user', component: () => import('./pages/UserPage.vue'), props: true },
    // /orders and /orders/31
    { path: '/orders/:id?', component: () => import('./pages/OrdersPage.vue') },
    // /docs/guide and /docs/guide/install, as an array
    { path: '/docs/:chapters+', component: () => import('./pages/DocsPage.vue') },
    // /p/25 only, never /p/vue
    { path: '/p/:sku(\\d+)', component: () => import('./pages/ProductPage.vue') },
    // anything that matched nothing else
    { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('./pages/NotFound.vue') },
  ],
})

Route by route:

  • /users/:id is a plain param. On /users/7, route.params.id is the string '7'. Param values are strings, because a URL is text; convert with Number() when you need a number.
  • ? makes a param optional. /orders matches as well as /orders/31, and on /orders the id param is an empty string.
  • + makes a param repeatable: one or more segments, collected into an array, so /docs/guide/install gives ['guide', 'install']. Use * for zero or more.
  • The part in parentheses is a regular expression. By default a param matches [^/]+, any characters except a slash. (\\d+) narrows it to digits, which lets two routes share a shape without clashing. The backslash is doubled because the pattern sits inside a JavaScript string.
  • /:pathMatch(.*)* catches everything else. .* lets the param contain slashes, and the trailing * keeps the path as an array of segments, so the 404 guard at the end can send a visitor here by name with the slashes intact.

The router ranks routes by how specific they are, not by their place in the array, so the catch-all does not have to come last, though it reads better there.

Receive params as props

Calling useRoute() inside a component ties it to one URL shape. The props: true on the user route above removes that tie: the router passes each param to the component as a prop of the same name.

src/pages/UserPage.vue
<script setup lang="ts">
defineProps<{ id: string }>()
</script>

<template>
  <h1>User {{ id }}</h1>
</template>

The component no longer knows a router exists. You can render it anywhere, and a test mounts it with mount(UserPage, { props: { id: '7' } }) instead of building a router first.

When you need to convert a value or pass something from the query, give props a function: props: route => ({ term: route.query.q }). Keep that function free of state, since the router only calls it when the route changes.

Reload data when only the param changes

Here is a mistake that is easy to make. The page loads its user through a small API helper:

src/api.ts
export interface User {
  id: number
  name: string
}

export async function fetchUser(id: string): Promise<User | null> {
  const res = await fetch(`/api/users/${encodeURIComponent(id)}`)
  if (res.status === 404) return null
  if (!res.ok) throw new Error(`Could not load user ${id}: ${res.status}`)
  return res.json()
}

It returns null for a 404, which the last section relies on. The page calls it once, when it mounts:

src/pages/UserPage.vueBreaks
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchUser, type User } from '../api'

const props = defineProps<{ id: string }>()
const user = ref<User | null>(null)

onMounted(async () => {
  user.value = await fetchUser(props.id)
})
</script>

<template>
  <h1>{{ user?.name }}</h1>
  <RouterLink :to="`/users/${Number(id) + 1}`">Next user</RouterLink>
</template>

Click "Next user". The address bar changes to /users/8, the id prop changes, and the heading still shows user 7. Moving from /users/7 to /users/8 renders the same component, so Vue Router reuses the instance instead of destroying it and building a new one. onMounted already ran, and it does not run again.

The fix is to treat the param as reactive data and watch it:

src/pages/UserPage.vueWorks
<script setup lang="ts">
import { onWatcherCleanup, ref, watch } from 'vue'
import { fetchUser, type User } from '../api'

const props = defineProps<{ id: string }>()
const user = ref<User | null>(null)

watch(
  () => props.id,
  async (id) => {
    let stale = false
    onWatcherCleanup(() => { stale = true })
    user.value = null
    const next = await fetchUser(id)
    if (!stale) user.value = next
  },
  { immediate: true },
)
</script>

<template>
  <h1>{{ user?.name }}</h1>
  <RouterLink :to="`/users/${Number(id) + 1}`">Next user</RouterLink>
</template>

immediate: true runs the loader as soon as the component is created, so the watcher replaces onMounted entirely. Setting user.value = null first clears the old name while the next one loads.

onWatcherCleanup, added in Vue 3.5, registers a function that runs just before the watcher runs again, and when it stops on unmount. Here it sets stale to true, so if the previous request is still in flight, its late answer is dropped. Without it, a fast double click could let the response for user 8 arrive after the one for user 9 and overwrite it.

There is a blunter fix: <RouterView :key="$route.fullPath" /> in App.vue forces a fresh component for every URL. It works, but it throws away all component state on every navigation, including query changes. Use the watcher.

Read and update the query string

The query is the part after ?. It plays no part in matching, so /search and /search?q=pinia&page=2 render the same route. That makes it the right place for filters, sorting and page numbers: state you want in a shareable link that the page can live without.

src/pages/SearchPage.vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

const term = computed(() => (typeof route.query.q === 'string' ? route.query.q : ''))
const page = computed(() => Number(route.query.page) || 1)

function goToPage(next: number) {
  router.push({ query: { ...route.query, page: next } })
}
</script>

<template>
  <h1>Results for "{{ term }}"</h1>
  <button type="button" @click="goToPage(page + 1)">Next page</button>
</template>

Each query value is typed as string | null, or an array of those. The array appears when a key repeats (?tag=vue&tag=pinia), and null when a key has no value (?q). The typeof check covers both.

Spreading route.query into the push keeps the other keys. Because the computed values read route.query, they update when the query changes, with no watcher needed.

Get typed routes from file-based routing

Vue Router 5 ships the file-based routing that used to live in unplugin-vue-router. Each file in src/pages becomes a route, and the plugin writes a declaration file that describes every route for TypeScript. The bracket names follow the same rules as the colon syntax: users/[id].vue is /users/:id, [[id]].vue is optional, [slugs]+.vue is repeatable and [...path].vue catches everything.

vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueRouter from 'vue-router/vite'

export default defineConfig({
  plugins: [
    // must come before vue()
    VueRouter({ dts: 'src/route-map.d.ts' }),
    vue(),
  ],
})

The dts option puts the generated types in src/, which most tsconfig.json setups already include (the default is typed-router.d.ts at the project root).

In src/router.ts, import routes from vue-router/auto-routes and pass it to createRouter in place of your array. Start the dev server once and src/route-map.d.ts appears. Inside it is an interface called RouteNamedMap with one entry per route: its name ('/users/[id]'), its path ('/users/:id'), the params you may pass in, and the params you read back. Commit it, as its header comment recommends.

src/pages/users/[id].vue
<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute('/users/[id]')
</script>

<template>
  <h1>User {{ route.params.id }}</h1>
  <RouterLink :to="{ name: '/users/[id]', params: { id: Number(route.params.id) + 1 } }">
    Next user
  </RouterLink>
</template>

The name passed to useRoute does nothing at runtime. It tells TypeScript which entry of RouteNamedMap to use, so route.params.id is a string and route.params.slug is an error. The RouterLink is checked too: a typo such as '/user/[id]' fails in your editor and in vue-tsc, and the param may be a number on the way in because the map says so.

Show a 404 when the record does not exist

/users/999 matches the user route perfectly well. The route exists; the user does not. The catch-all route only fires for paths that match nothing, so this case needs a guard:

src/router.ts
import { fetchUser } from './api'

// below the createRouter() call
router.beforeEach(async (to) => {
  if (to.name !== 'user') return
  const user = await fetchUser(String(to.params.id))
  if (!user) {
    return {
      name: 'not-found',
      params: { pathMatch: to.path.substring(1).split('/') },
      query: to.query,
      hash: to.hash,
    }
  }
})

Returning a location from a guard redirects the navigation. Passing the current path as pathMatch keeps /users/999 in the address bar while NotFound.vue renders, so the visitor sees which link was broken. With file-based routing, compare to.name with '/users/[id]' instead of 'user', and the generated types will catch a typo there too.

Use beforeEach here, not a beforeEnter on the route. beforeEnter only runs when you arrive from a different route, so going from /users/7 to /users/999 would skip it: the same reuse that caught the component earlier. The cost is a second request when the page loads the same user; cache the result in the helper if that matters to you.

Check it stuck3 questions from the Dynamic routes and params quiz.
Practice now
Was this helpful?

Keep reading