Skip to content
wannacode

Routing

In short: Routing connects the address with the page: Vue Router shows the component of the route, links do not reload, and params like /product/:id come from the address.

Problem

A link that leads nowhere

The shop has a catalogue and a page for each product. Open "Fresh milk" and go back.

Vue

It looks fine. But which page you see lives only in the state of the component (productId). The address in the browser is the same for the catalogue and for every product. So:

  • you cannot send a friend a link to the milk: they will get the catalogue;
  • "Reload" takes you back to the catalogue, because the state is lost;
  • "Back" in the browser does not return to the catalogue, it leaves the shop;
  • the products are buttons, not links: "Open in new tab" does not work.

(The sandbox has no address bar, so these things do not show in it. On a real site you see them at once.)

The idea

The address is the state

Every page needs its own address: / for the catalogue, /product/5 for the milk, /cart for the cart. Then the address says which page to show, not the state in memory.

The app stays one page (a single-page application): the browser loads the HTML once. On a link click the library stops the reload, changes the address through the browser's History API and draws another component. This is what the router does. It has a table of routes: an address pattern and a component for it.

Vue Router gets the table as an array. The router is created in its own file and added to the app with use:

router.js
import { createRouter, createMemoryHistory } from 'vue-router';
export const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: Catalog },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
],
});
// main.js
createApp(App).use(router).mount('#app');

In App.vue the place for the page is <RouterView />. There the router draws the component that matches the address. Everything around it (the menu at the top) shows on every page.

:id in /product/:id is a param: it stands for one segment of the address. One route covers all products.

See it

See how the address picks the page

<RouterLink :to="`/product/${p.id}`">{{ p.name }}</RouterLink>
const routes = [
{ path: '/', component: Catalog },
{ path: '/product/:id', component: ProductPage },
{ path: '/cart', component: CartPage },
{ path: '/:pathMatch(.*)*', component: NotFound },
];
<!-- ProductPage.vue -->
const route = useRoute();
const product = computed(() =>
PRODUCTS.find((p) => p.id === Number(route.params.id)),
);
<h1>{{ product.name }}</h1>
<!-- NotFound.vue -->
<h1>No such page</h1>
Step by step, in words
  1. The router has a table: an address pattern and the component for it. The address now is /, the catalogue.
  2. A click on "Fresh milk". The router stops the page from reloading and only changes the address to /product/5.
  3. It compares the address with every row. "/product/:id" matches: :id stands for any one segment. The catch-all matches too, but it is more general and loses.
  4. From the match comes the param: id = "5". It is text, because the address is text. ProductPage reads route.params.id, and Number(…) turns it into 5.
  5. ProductPage finds product 5 and draws its page. The same component draws every product: only the param is different.
  6. Now the address is /sale, and there is no such page yet. No row matches except the last one, so the router draws NotFound.
  7. The address is the state of the page. You can share it, reload it and go back to it, and the table always gives the same page.

The router knows nothing about products. It compares the text of the address with the patterns and passes the param to the component. What 5 means is up to the component.

The idea

In the sandbox and in a real project

The router of a real site reads and writes the address in the browser's address bar. That is createWebHistory(). The sandbox in the lessons is a page inside the lesson page and has no address of its own. So here it gives a clear error:

Vue

In the sandbox we use createMemoryHistory(): a router that keeps the address in memory. Everything else is the same: the table, the links, the params. We show the address ourselves, at the bottom of the page.

In a real project only the history changes:

import { createRouter, createWebHistory } from 'vue-router';
export const router = createRouter({
history: createWebHistory(),
routes,
});

And one more thing the sandbox does not show: the server. When someone opens /product/5 directly or presses "Reload", the browser asks the server for that address. There is no such file. The server must return index.html for every address that is not a file, and then the router picks the page. The Vite dev server does this by itself. For nginx the try_files line does it:

location / {
try_files $uri /index.html;
}

Try it

A page for every product

The table has the catalogue and the cart, and the products lead to /product/1, /product/5 and /product/7, but there is no such route. Add a route with a param for the product page. ProductPage now always shows the bread: let it take the id from the address. The check opens the milk, then the cheese from "See also", then the bread from the catalogue and looks at the heading and the address.

Vue

Try it

Find the bug

Every product page says "No such product", and the products are in the data. Which line is to blame?

Pick the line with the bug.

The idea

Links and moving from code

Links. A page of the app is reached with <RouterLink to="/cart">. In the page it is a plain <a href="/cart">: "Open in new tab" works and a screen reader reads it as a link. On a plain click the router stops the reload (preventDefault) and only changes the address. If you write <a href> yourself, the browser loads the whole site again, and the cart in memory is gone.

For the menu there is RouterLink with the class the router gives it: the link to the current page gets aria-current="page" and can have its own style. So both the eye and the screen reader know where you are.

Moving from code. Sometimes the page must change after an action, not after a link click: "Add to cart" adds the product and takes you to the cart.

import { useRouter } from 'vue-router';
const router = useRouter();
function buy() {
add(product.value);
router.push('/cart');
}

useRouter() gives the router and is called in <script setup>, not in the handler. router.push changes the address like a link click. useRoute() (no r) gives the current address and its params.

You use a link when a person chooses where to go. You move from code when the page changes because of the result of an action.

Try it

Fill in

Where does the product page get the id from the address?

<script setup>
const route = ();
const product = computed(() =>
  PRODUCTS.find((p) => p.id === Number(route.params.id)),
);
</script>

The idea

No such page

If the address matches no route, the router draws nothing. The person sees an empty space under the menu and does not know what happened. So the last route catches everything else and draws "No such page" with a link to the catalogue.

{ path: '/:pathMatch(.*)*', component: NotFound },

pathMatch is the name of a param, and (.*) says "any text, slashes included". Vue Router ranks the routes by how exact they are, so this one wins only when no other matches. The old path: '*' from Vue Router 3 no longer works: the router throws an error.

A page for a product that does not exist (/product/99) is a different case: the route matches, but the product is missing. That is up to the component, so ProductPage says "No such product".

More precisely, if you are curious

Nested routes. Pages often share a frame: the account with a menu on the left and sub-pages "Orders" and "Addresses". The route has children, and the parent has its own <RouterView /> for the child.

A query in the address. Search and filters are also state that is worth putting in the address: /?q=milk&category=2. It is read from route.query and written with router.push({ query: { q: 'milk' } }).

The same component, a new param. From /product/5 to /product/7 Vue does not create a new ProductPage, it changes route.params. So whatever is calculated from the param goes in computed, and a request to the API when the param changes goes in watch(() => route.params.id, …).

Named routes. { path: '/product/:id', name: 'product', component: ProductPage } allows router.push({ name: 'product', params: { id: 5 } }): the address is built from the table, not by hand.

Guards. The "Checkout" page may require a login. The router can check before the move and redirect to the login: with router.beforeEach.

Exercise

The cart and 404

The menu has "Sale" (/sale), but that page does not exist yet and the space under the menu stays empty. And "Add to cart" adds the product, but the person stays on the product page. Add a route for everything else to NotFound (the component is ready) and make the button go to /cart after adding. The check opens "Sale", goes back with "Back to the catalogue", buys bread and cheese and looks at the cart, the address and the total.

Vue

Test

Check yourself

1. The table has "/", "/product/:id" and a route for everything else. The address is /product/12. What does the router draw?
2. Why does the shop menu use Link / RouterLink and not a plain <a href="/cart">?
3. ProductPage.vue has const product = PRODUCTS.find((p) => p.id === Number(route.params.id)); without computed. From "Fresh milk" you click "White cheese" under "See also". What happens?
4. In the real shop you open /product/5 and press "Reload". The server answers 404. What is missing?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·