In Vue Router 5, what does `<RouterLink>` render and do on click, and what do you lose navigating with `router.push()` from a `<div>`?
answer
- a real element, a real URL
- href built by router.resolve
- click filter: modifiers, button, _blank
- preventDefault, then push or replace
- a div has no href to open
basics
~20 s<RouterLink> renders a real <a> whose href the router resolves, and a plain left click calls router.push() (or router.replace() with replace). A clickable <div> calling router.push() has no URL: no new tab, copy-link or keyboard focus.
solid answer
~40 sIn Vue Router 5, `<RouterLink :to>` renders an `<a>` whose `href` comes from `router.resolve(to)`, so named locations are encoded and the history base is applied. Its click handler lets the browser handle Ctrl/Meta/Alt/Shift clicks, non-primary buttons, `target="_blank"` and already-prevented events; otherwise it calls `preventDefault()` and `router.push(to)`, or `router.replace(to)` with the `replace` prop. When the link matches the current route it also adds `router-link-active`, `router-link-exact-active` and `aria-current`. A `<div @click="router.push(...)">` navigates for a mouse user but puts no URL in the DOM, so opening in a new tab, copying the link and tabbing to it all break. I keep `router.push()` for navigation that results from logic, such as after a save, and `await` its Promise.
code
vue · 22 lines<script setup lang="ts">
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { saveSearch } from '@/api/searches'
defineProps<{ job: { id: string; title: string } }>()
const route = useRoute()
const router = useRouter()
async function onSaveSearch() {
const saved = await saveSearch(route.query)
await router.push({ name: 'saved-search', params: { id: saved.id } })
}
</script>
<template>
<article class="job-card">
<RouterLink :to="{ name: 'job', params: { id: job.id } }">
{{ job.title }}
</RouterLink>
<button type="button" @click="onSaveSearch">Save this search</button>
</article>
</template>go deeper
Recall that RouterLink renders a real anchor with a resolved href and calls router.push on a plain left click, and that router.push returns a Promise.
Explain the click filter: modifier keys, non-primary buttons, target _blank and prevented events go to the browser; every other click becomes a push, or a replace with the replace prop.
Show that you catch href-less navigation in review: clickable cards need a real RouterLink, while router.push belongs to moves that follow logic, such as after a save.
Frame it as a team convention: links for places, push for outcomes, enforced in the design system's link component so href-less navigation does not spread.
## What `<RouterLink>` renders `<RouterLink>` is the link component Vue Router 5 registers when you call `app.use(router)`. By default it renders a real **`<a>` element**. Its `href` is not the raw `to` value: the router runs `to` through `router.resolve()`, so a named location such as `{ name: 'job', params: { id: '42' } }` becomes a fully encoded URL, and the history mode's base is applied (in hash mode the `href` contains `#/jobs/42`). The `to` prop accepts the same values as `router.push()`: - a **string path** such as `'/jobs/42?ref=search'`; - a **path location** with `path`, `query` and `hash`; - a **named location** with `name`, `params`, `query` and `hash`. It also sets two CSS classes when the link matches the current route, and `aria-current="page"` when it matches exactly (the `ariaCurrentValue` prop changes that value). ## What happens on click The rendered `<a>` carries a click handler called `navigate`. Before it does anything, it checks the event and steps aside when the browser should handle the click itself: 1. a modifier key is held (Ctrl, Meta, Alt or Shift); 2. `event.defaultPrevented` is already true; 3. the button is not the primary (left) button; 4. the element has `target="_blank"`. In those cases the browser follows the `href`, so Ctrl+click opens the job posting in a new tab. Otherwise the handler calls `preventDefault()`, so no full page load happens, and then calls `router.push(to)`, or `router.replace(to)` when the link has the `replace` prop. RouterLink catches the promise's rejection so a failed navigation does not surface as an unhandled rejection; the router still reports the error to `router.onError()` handlers, or to the console when none is registered. ## RouterLink versus calling `router.push()` | | `<RouterLink :to>` | `router.push(to)` | |---|---|---| | Output | an `<a href>` in the DOM | nothing rendered | | New tab, copy link, middle-click | work, the `href` is real | impossible without an `href` | | Keyboard focus and Enter | built into `<a href>` | only if you add them | | Active classes and `aria-current` | applied automatically | none | | Result | promise handled internally | a Promise you can `await` | | History | push, or replace with `replace` | push, or `router.replace()` | The two share one engine: clicking `<RouterLink :to="x">` is the same navigation as `router.push(x)`. The same guards run and the same location rules apply. ## Why a clickable `<div>` is a bug On a job-search results page it is tempting to make each job card clickable with `<div @click="router.push(...)">`. It works for a mouse user, but the card has **no URL in the DOM**: - the user cannot open three postings in background tabs or copy a posting's link; - the status bar shows no target on hover; - a keyboard user cannot tab to the card or press Enter on it; - a crawler finds no link to follow. Put a `<RouterLink>` around the card's title (and stretch it over the card with CSS if the whole card must be clickable) instead. ## When `router.push()` is the right call Programmatic navigation fits when moving is the **result of logic**, not a place the user points at: - after an async action, such as saving a search and opening the saved search; - after a form submit, once the server has answered; - when the target depends on state only code knows, such as the next unread posting. `router.push()` returns a **Promise** that settles when the navigation finishes, so code can `await` it before closing a menu or showing a message. `router.replace()` does the same without adding a history entry, and `router.go(n)`, `router.back()` and `router.forward()` move through the history stack like `window.history.go()`; `router.go()` fails silently when there are not that many entries. ## Common mistakes - Expecting RouterLink to render a `<span>` or `<button>` through a prop: the Vue Router 3 `tag` and `event` props were removed in v4. Use the `custom` prop and its slot to render your own markup. - Building URLs by hand with unencoded values instead of passing a named location and letting the router encode params. - Treating `router.push()` as synchronous and running follow-up code before the navigation has finished. - Using `router.back()` for a *Back to results* button without checking that an in-app previous entry exists.
- A job page's Back to results button calls `router.back()`. When is that wrong, and what is safer?When the user arrived from a shared link or a new tab there is no earlier in-app entry, so `router.back()` leaves the app or does nothing; `router.go()` fails silently when the stack is too short. Vue Router's HTML5 history writes a `back` field into `history.state` and sets it to `null` on the first entry, so check `window.history.state?.back` and fall back to `router.push({ name: 'jobs' })`.
- What does RouterLink's `replace` prop change?The click calls `router.replace()` instead of `router.push()`, so the current history entry is overwritten rather than a new one added. It is the declarative twin of passing `replace: true` in a location object. The `href`, the guards and the active classes are unchanged.
saying these in an interview costs you the question
- RouterLink renders a button, so it needs no href to work
- A div that calls router.push is equivalent to a RouterLink
- Clicking a RouterLink reloads the page like an ordinary anchor
- Ctrl+click on a RouterLink still navigates inside the current tab
- Use RouterLink's tag prop to render a button instead of an anchor
- router.push is synchronous, so the next line already runs on the new route