Vue.js 56 🟢 Dynamic Route Matching, Route Params, and Query Strings
A URL like /users/42 and a URL like /users/42?tab=posts are both requests for the same resource, but they carry different kinds of information. The 42 is part of the path—it identifies which user. The tab=posts is a query string—it describes how to display that user. Vue Router treats these two as distinct concepts with distinct mechanics: params are embedded in the path pattern and affect which route matches, while query parameters are appended after the ? and never affect route matching . Confusing the two leads to routes that do not match, components that do not update, and URLs that break when the data changes.
The mechanics are simple once the distinction is clear. A param is declared with a colon in the route path, like /users/:id, and the matched value is exposed as route.params.id. A query parameter requires no route configuration at all—it is parsed from the URL automatically and exposed as route.query.tab . The harder part is not reading the values but reacting to changes. When the user navigates from /users/42 to /users/43, Vue Router reuses the same component instance for efficiency, which means lifecycle hooks like mounted do not fire again. The component must watch the param or use the onBeforeRouteUpdate guard to reload the data .
Key point: Params are declared with :name in the route path, affect route matching, and are exposed as route.params; query strings are appended after ?, do not affect matching, and are exposed as route.query; because Vue Router reuses the component instance when only params change, the component must watch the specific param or use onBeforeRouteUpdate to react.
Why params and query are different mechanisms
The matching problem. A route path is a pattern. /users/:id is a pattern that matches /users/42, /users/alice, and any other single segment after /users/. The :id is a placeholder that captures whatever appears in that segment. A query string is not part of the pattern. /users/42?tab=posts matches the same /users/:id route as /users/42, because the query is stripped before matching . This is why a query parameter can be added to any URL without changing which component renders.
The identity problem. Params identify a resource. /users/42 and /users/43 are different resources—different users. Query parameters modify how a resource is presented. /users/42?tab=posts and /users/42?tab=settings are the same user, displayed differently. The URL structure reflects this: params are part of the path hierarchy, query parameters are optional modifiers attached to the end .
The type problem. Every value in route.params and route.query is a string, regardless of what it looks like. A URL of /users/42 produces route.params.id as the string "42", not the number 42. A query of ?page=2 produces route.query.page as "2". Converting to a number, boolean, or other type is the component’s responsibility . This is a common source of bugs when comparing a param against a number or using it in arithmetic.
The reuse problem. When only the param changes—/users/42 to /users/43—Vue Router reuses the existing component instance. This is an optimization: destroying and recreating the component would be wasteful when the template is the same and only the data needs to change. The consequence is that mounted and created do not run again. The component must explicitly watch the param or use a navigation guard to fetch the new data .
a. Declaring params and reading them in components
A param is declared with a colon in the route’s path. The name after the colon becomes the key in route.params. Multiple params can appear in the same path, and each maps to its own field.
// router.js
import { createRouter, createWebHistory } from 'vue-router'
import User from './User.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
// :id is a param — matches /users/42 and /users/alice
{ path: '/users/:id', component: User },
// multiple params in one path
// matches /users/42/posts/7
{ path: '/users/:userId/posts/:postId', component: Post },
],
})
In the component template, the current route is available as $route. The param is accessed as $route.params.id:
<!-- User.vue -->
<template>
<div>
<!-- $route.params.id is the value captured from the URL -->
<h1>User {{ $route.params.id }}</h1>
</div>
</template>
In <script setup>, the useRoute composable returns the reactive route object. useRouter returns the router instance for programmatic navigation:
<script setup>
import { useRoute } from 'vue-router'
// useRoute() replaces this.$route in the Composition API
const route = useRoute()
// route.params.id is a string, even if the URL contains only digits
console.log(route.params.id)
</script>
The useRoute composable is necessary because <script setup> has no this. The template still has access to $route, so useRoute is only needed when the param is used in script logic .
b. Reading and updating query strings
Query parameters require no route configuration. They are parsed automatically from the URL and exposed as route.query. A URL of /search?q=vue&page=2 produces route.query as { q: 'vue', page: '2' }.
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const router = useRouter()
// Query values are strings — convert to number for pagination
const page = computed(() => Number(route.query.page) || 1)
const searchTerm = computed(() => route.query.q || '')
// Update a query parameter while preserving existing ones
function goToPage(n) {
router.push({
// Spread route.query first to keep q, sort, etc.
query: { ...route.query, page: n },
})
}
</script>
The spread operator in router.push({ query: { ...route.query, page: n } }) is important. Without it, pushing { page: n } replaces the entire query string, and the q parameter disappears . The pattern preserves existing parameters and only changes the one that the user interacted with.
When navigating with router.push, params are ignored if a path is provided. To use params with programmatic navigation, provide name instead of path:
// params are ignored because path is provided
router.push({ path: '/users/42', params: { id: '99' } }) // → /users/42
// params work because name is provided
router.push({ name: 'user', params: { id: '99' } }) // → /users/99
This rule applies to <RouterLink> as well. The to prop accepts the same object as router.push, so :to="{ name: 'user', params: { id } }" works, but :to="{ path: '/users', params: { id } }" silently drops the param .
c. Reacting to param changes and catch-all routes
When the user navigates from /users/42 to /users/43, the User component is reused. The mounted hook does not fire again. To fetch the new user, the component must watch the specific param:
<script setup>
import { useRoute } from 'vue-router'
import { ref, watch } from 'vue'
const route = useRoute()
const user = ref(null)
// Watch only the param that matters, not the whole route object
watch(
() => route.params.id,
async (newId) => {
// Fetch the user for the new ID
user.value = await fetchUser(newId)
},
{ immediate: true }, // run immediately for the initial load
)
</script>
Watching route.params.id instead of the entire route object is deliberate. The route object is reactive, and watching it wholesale causes the callback to fire on every navigation, including query changes and hash changes that are irrelevant to the user data. Watching the specific param filters out those unrelated changes .
The alternative is the onBeforeRouteUpdate guard, which fires before the route changes and allows cancelling the navigation:
<script setup>
import { onBeforeRouteUpdate } from 'vue-router'
import { ref } from 'vue'
const user = ref(null)
// Fires when the same component is reused with different params
onBeforeRouteUpdate(async (to, from) => {
// Only fetch if the id actually changed
if (to.params.id !== from.params.id) {
user.value = await fetchUser(to.params.id)
}
})
</script>
The guard receives to and from route objects, so the comparison to.params.id !== from.params.id distinguishes a param change from a query-only change .
For 404 pages and catch-all routes, the path uses a custom regex param. The pattern /:pathMatch(.*)* matches any path, captures it as pathMatch, and the * makes it repeatable so the captured value is an array rather than a string:
// Matches any path not matched by earlier routes
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
The * after the parentheses is what makes the param repeatable, which means /a/b/c is captured as ['a', 'b', 'c'] rather than the string 'a/b/c' . This is the route that renders the 404 component for any URL that did not match a defined route.
Complete Example Session
The following session builds a user profile route with a param, a tab query string, and a watcher that reloads the user when the param changes.
// ============================================
// PART 1: THE ROUTE DEFINITION
// ============================================
import { createRouter, createWebHistory } from 'vue-router'
import UserProfile from './UserProfile.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
// :id is a param — matches /users/42 and /users/alice
path: '/users/:id',
name: 'user',
component: UserProfile,
},
],
})
// ============================================
// PART 2: THE COMPONENT
// ============================================
// UserProfile.vue
<script setup>
import { useRoute, useRouter } from 'vue-router'
import { ref, watch, computed } from 'vue'
const route = useRoute()
const router = useRouter()
// The user data loaded for the current :id param
const user = ref(null)
// ============================================
// PART 3: READ THE QUERY PARAMETER
// ============================================
// ?tab=posts → route.query.tab is "posts"
// Default to "profile" when the query is absent
const activeTab = computed(() => route.query.tab || 'profile')
// ============================================
// PART 4: REACT TO PARAM CHANGES
// ============================================
// The component is reused when only :id changes
// so watch the param and fetch the new user
watch(
() => route.params.id,
async (newId) => {
// newId is a string from the URL
user.value = await fetchUser(newId)
},
{ immediate: true }, // run for the initial load
)
// ============================================
// PART 5: UPDATE THE QUERY STRING
// ============================================
function switchTab(tab) {
router.push({
// Spread existing query to preserve other params
query: { ...route.query, tab },
})
}
</script>
// ============================================
// PART 6: THE TEMPLATE
// ============================================
<template>
<div v-if="user">
<!-- Param value is a string; display it directly -->
<h1>{{ user.name }} (ID: {{ $route.params.id }})</h1>
<!-- Query value controls which tab is active -->
<nav>
<button @click="switchTab('profile')">Profile</button>
<button @click="switchTab('posts')">Posts</button>
</nav>
<!-- Render based on the computed activeTab -->
<section v-if="activeTab === 'profile'">Profile content</section>
<section v-else-if="activeTab === 'posts'">Posts content</section>
</div>
</template>
The six parts cover the route definition with a param, the component setup, reading the query parameter with a default, watching the param for changes, updating the query string while preserving existing values, and the template that displays both. What the session demonstrates is that params and query are read differently, react differently, and are updated differently—and the component handles both without remounting.
Quick Reference
Params vs Query
| Aspect | Params | Query |
|---|---|---|
| Declaration | :name in route path | No route config |
| URL position | Path segments | After ? |
| Affects matching | Yes | No |
| Access | route.params.name | route.query.name |
| Type | Always string | Always string |
| Component reuse | Yes, when only params change | Yes |
Navigation with Params
| Method | Works? | Reason |
|---|---|---|
router.push({ name, params }) | ✅ | Name resolves the path |
router.push({ path, params }) | ❌ | Params ignored with path |
router.push('/users/42') | ✅ | Literal path |
Reacting to Changes
| Approach | Use Case |
|---|---|
watch(() => route.params.id, fn) | Fetch data on param change |
onBeforeRouteUpdate(to, from) | Guard before navigation |
watch(() => route.query, fn) | React to query changes |
Catch-All Route
| Pattern | Behavior |
|---|---|
/:pathMatch(.*)* | Matches any path, array param |
/:pathMatch(.*)+ | Matches one or more segments |
Best Practices
✅ Do This:
// Declare the param in the route path
{ path: '/users/:id', component: User }
// Use name for programmatic navigation with params
router.push({ name: 'user', params: { id: 42 } })
// Watch the specific param, not the whole route
watch(() => route.params.id, fetchUser)
// Spread query to preserve existing parameters
router.push({ query: { ...route.query, page: 2 } })
❌ Don’t Do This:
// Using path with params silently drops the params
router.push({ path: '/users', params: { id: 42 } })
// Watching the whole route fires on irrelevant changes
watch(() => route, fetchUser)
// Assuming params are numbers
if (route.params.id === 42) // always false, it's "42"
// Replacing the entire query string
router.push({ query: { page: 2 } }) // loses ?q=vue
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Component not updating | Lifecycle hook not re-fired | Watch the param or use onBeforeRouteUpdate |
Param is undefined | Used path instead of name | Use name with params |
| Number comparison fails | Param is a string | Convert with Number() |
| Query lost on update | Replaced instead of spread | { ...route.query, newKey } |
| 404 route not matching | Catch-all defined before other routes | Put it last |
Real-World Examples
1. Param route definition
// :id captures the segment after /users/
{ path: '/users/:id', component: User }
2. Read param in template
<!-- $route.params.id is the matched value -->
<h1>User {{ $route.params.id }}</h1>
3. Read param in script
// useRoute replaces this.$route in script setup
const route = useRoute()
const id = route.params.id // string
4. Read query in script
// query is parsed automatically from the URL
const page = Number(route.query.page) || 1
5. Watch param
// Only fires when :id changes, not on query changes
watch(() => route.params.id, fetchUser)
6. Update query
// Spread existing query to keep other parameters
router.push({ query: { ...route.query, tab: 'posts' } })
7. Navigation with name
// name resolves the path; params are used
router.push({ name: 'user', params: { id: 42 } })
8. Catch-all route
// * makes the param repeatable (array)
{ path: '/:pathMatch(.*)*', component: NotFound }
9. Guard on update
// to and from contain the route objects for comparison
onBeforeRouteUpdate((to, from) => {
if (to.params.id !== from.params.id) fetchUser(to.params.id)
})
10. Optional param
// ? makes the param optional
{ path: '/users/:id?', component: User }
Visual
URL Structure
┌──────────────────────────────────────────────┐
│ /users/42?tab=posts │
│ ─────┬─── ─┬─ ───┬──── │
│ │ │ │ │
│ │ │ └── query: route.query.tab│
│ │ │ │
│ │ └── param: route.params.id │
│ │ │
│ └── static path segment │
│ │
│ Params are part of the pattern. │
│ Query is attached after ?. │
└──────────────────────────────────────────────┘
Component Reuse
┌──────────────────────────────────────────────┐
│ /users/42 → User component mounted │
│ │ │
│ ▼ │
│ /users/43 → same component reused │
│ │ │
│ ▼ │
│ mounted() does NOT fire again │
│ │ │
│ ▼ │
│ watch(() => route.params.id) fires │
│ → fetchUser(43) │
└──────────────────────────────────────────────┘
Params with Name vs Path
┌──────────────────────────────────────────────┐
│ router.push({ name: 'user', params: { id } })│
│ → /users/42 ✅ │
│ │
│ router.push({ path: '/users', params: { id } })│
│ → /users ❌ params ignored │
└──────────────────────────────────────────────┘
Query Preservation
┌──────────────────────────────────────────────┐
│ Current: /search?q=vue&page=1 │
│ │ │
│ ▼ │
│ router.push({ query: { page: 2 } }) │
│ → /search?page=2 ❌ q lost │
│ │
│ router.push({ query: { ...route.query, page: 2 } })│
│ → /search?q=vue&page=2 ✅ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Param declaration | :name in route path |
| Param access | route.params.name |
| Query access | route.query.name |
| Param type | Always string |
| Query type | Always string |
| Component reuse | Yes, when only params change |
| Watch param | watch(() => route.params.id, fn) |
| Guard on update | onBeforeRouteUpdate(to, from) |
| Catch-all | /:pathMatch(.*)* |
| Navigation with params | Use name, not path |
Key takeaways:
- Params and query are different mechanisms. Params are part of the route path and affect matching. Query parameters are appended after
?and never affect matching . - Every value is a string.
route.params.idandroute.query.pageare strings regardless of the URL content. Convert to numbers or booleans explicitly . - The component is reused when only params change.
mounteddoes not fire again. Watch the specific param or useonBeforeRouteUpdateto reload data . - Use
namefor navigation with params.router.push({ path, params })silently drops the params.router.push({ name, params })works . - Spread query to preserve existing parameters.
{ ...route.query, newKey }keeps the parameters that the user did not change . - The catch-all route uses a repeatable regex param.
/:pathMatch(.*)*captures any path as an array and should be the last route in the configuration .
Remember: A URL carries two kinds of information: what resource to display and how to display it. Params answer the first question—they are part of the path, they affect matching, and they identify a specific item. Query parameters answer the second—they are appended after the ?, they do not affect matching, and they modify the presentation. The component reads both from the reactive route object, but it must treat them differently: params require watching because the component is reused, query parameters require spreading because replacing them loses data. The distinction is not cosmetic—it is the difference between a route that updates correctly and one that silently shows stale data.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!