Navigating in Code
Navigate from code with push, replace and back, carry state in the query string, and return a user to where they came from.
router.push({ name: 'user', params: { id }, query: { tab: 'posts' } })Links cover most navigation, but some happens after work finishes: a saved form, a successful sign-in, a cancelled dialog. For those you call the router. useRouter() returns the router instance, the thing you act with; useRoute() returns the current location, the thing you read. Mixing them up is the usual first mistake.
push, replace and back
router.push adds a history entry, so the back button returns to the previous screen. It accepts the same values as to on a link: a path string, or a location object with path or name plus params, query and hash. router.replace(location) swaps the current entry instead of adding one, and is the same call as router.push({ ...location, replace: true }). It is what you want after a login or a redirect, so back does not land on the page you just left. router.back() is the same as router.go(-1), and router.go(n) moves n entries in either direction.
Parameters, query and the path trap
params are ignored when you pass path. router.push({ path: '/users/:id', params: { id } }) navigates to that literal string; use name with params, and the router builds and encodes the URL. query works with both. A location object with neither path nor name keeps the current path, which makes updating a filter in the query a one-liner.
Sending the user back
Store the place they were going in the query, as redirect: to.fullPath, then replace to it after sign-in. Validate the value before using it: an attacker-supplied absolute URL does not belong in a redirect.
Example
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const email = ref('')
const error = ref('')
function signIn(): void {
if (!email.value.includes('@')) {
error.value = 'Enter an email address'
return
}
// only accept an in-app path, never an absolute URL
const wanted = route.query.redirect
const target =
typeof wanted === 'string' && wanted.startsWith('/') ? wanted : '/'
// replace, so back does not return to the login screen
void router.replace(target)
}
</script>
<template>
<form @submit.prevent="signIn">
<label for="email">Email</label>
<input id="email" v-model="email" type="email" autocomplete="email" />
<p v-if="error">{{ error }}</p>
<button type="submit">Sign in</button>
</form>
</template>
<style scoped>
form {
display: grid;
gap: 0.5rem;
max-width: 20rem;
}
</style>When to use it
- Send a user to the record they just created once the save succeeds
- Return a user to the page they asked for after they sign in
- Store a list's filters in the URL so the view survives a refresh and can be shared
More examples
Keeping a filter in the query string
The URL holds the filter, so the view can be reloaded, bookmarked and shared, and the component reads it instead of keeping a second copy. replace rather than push keeps the back button useful: ten filter clicks would otherwise be ten history entries. Spreading route.query first preserves any other parameters already in the URL. Query values are strings or arrays of strings, which is why the computed checks the type.
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const options = ['all', 'open', 'closed'] as const
const status = computed(() =>
typeof route.query.status === 'string' ? route.query.status : 'all',
)
function setStatus(next: string): void {
// no path and no name: the current path is kept
void router.replace({ query: { ...route.query, status: next } })
}
</script>
<template>
<div>
<button
v-for="option in options"
:key="option"
type="button"
:aria-pressed="status === option"
@click="setStatus(option)"
>
{{ option }}
</button>
<p>Showing: {{ status }}</p>
</div>
</template>
<style scoped>
button {
margin-right: 0.5rem;
}
</style>Pushing a named route from a plain module
useRouter only works inside a component's setup, so code outside one takes the router as an argument instead; passing it in also keeps the function testable with a stub. Query values must be strings, so numbers are converted before they go in. The commented line is the mistake worth recognising: it compiles, it navigates, and the address bar shows a literal colon.
// src/features/navigation.ts
import type { Router } from 'vue-router'
export function openMemberPosts(
router: Router,
id: string,
page: number,
): void {
// Wrong: params are dropped when path is given.
// router.push({ path: '/members/:id/posts', params: { id } })
void router.push({
name: 'member-posts',
params: { id },
query: { page: String(page) },
})
}Finding out whether the navigation happened
push returns a promise. It resolves to a falsy value when the navigation went through, including after a redirect, and to a navigation failure object when it did not. The three failure types are aborted (a guard returned false), cancelled (a newer navigation overtook this one) and duplicated (you are already there). Check it when the next step depends on having actually arrived, for instance before clearing a cart or showing a confirmation.
// src/features/checkout.ts
import {
NavigationFailureType,
isNavigationFailure,
type Router,
} from 'vue-router'
export async function goToCheckout(router: Router): Promise<boolean> {
const failure = await router.push({ name: 'checkout' })
if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
// a guard returned false
return false
}
return !isNavigationFailure(failure)
}
Discussion