Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Vue Router & Data Fetching

Client-side routing, params, guards and loading states.

A single-page application (SPA) loads one HTML document and then swaps components in and out as the user navigates. The page never fully reloads. Vue Router is the official library that maps URLs to components, keeps the address bar and browser history in sync, and gives you hooks to protect or prepare each navigation.

This lesson builds the routing for the Task board: a list page, a task detail page with a URL parameter, a protected settings area and lazy-loaded routes. Then it covers the part every real app needs: fetching data for a route with proper loading and error states.

How client-side routing works

When you click a <RouterLink>, Vue Router:

  1. Calls history.pushState() to change the URL without a request to the server.
  2. Matches the new path against your route table.
  3. Runs navigation guards, which can cancel or redirect the navigation.
  4. Renders the matched component inside <RouterView>.

The browser's back and forward buttons fire a popstate event, which Vue Router listens for and handles the same way.

RouterLink click /tasks/42 Match route /tasks/:id router.beforeEach global guard (auth) beforeEnter per-route guard Resolve lazy import('./TaskView') Render <RouterView /> afterEach analytics, title return '/login' or false ok blocked

History modes

Mode URL looks like Server config needed?
createWebHistory() /tasks/42 Yes. Every unknown path must return index.html.
createWebHashHistory() /#/tasks/42 No. The hash is never sent to the server.
createMemoryHistory() (not in the URL) No. Used for SSR and tests.

With HTML5 history mode, a user who refreshes on /tasks/42 sends that path to the server. In Laravel, a catch-all route handles it: Route::view('/{any}', 'app')->where('any', '.*');.

Defining routes

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import TaskList from '@/views/TaskList.vue';

const routes = [
    { path: '/', redirect: '/tasks' },
    { path: '/tasks', name: 'tasks', component: TaskList },
    {
        path: '/tasks/:id(\\d+)',               // only numeric ids match
        name: 'task',
        component: () => import('@/views/TaskView.vue'),   // lazy-loaded chunk
        props: route => ({ id: Number(route.params.id) }), // pass params as props
    },
    {
        path: '/settings',
        component: () => import('@/views/SettingsLayout.vue'),
        meta: { requiresAuth: true },
        children: [
            { path: '', name: 'settings', component: () => import('@/views/SettingsProfile.vue') },
            { path: 'billing', name: 'billing', component: () => import('@/views/SettingsBilling.vue') },
        ],
    },
    { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFound.vue') },
];

export const router = createRouter({
    history: createWebHistory(),
    routes,
    scrollBehavior: (to, from, saved) => saved ?? { top: 0 },
});
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';

createApp(App).use(router).mount('#app');
<!-- App.vue -->
<template>
    <nav>
        <RouterLink to="/tasks">Tasks</RouterLink>
        <RouterLink :to="{ name: 'settings' }">Settings</RouterLink>
    </nav>
    <RouterView />
</template>

<RouterLink> renders an <a> with the correct href, so middle-click and "open in new tab" still work. It adds router-link-active / router-link-exact-active classes for styling.

Lazy routes. component: () => import('./TaskView.vue') tells Vite to split that view into its own JavaScript chunk, downloaded only when the route is first visited. On a large app this is the single biggest win for initial load time.

Params, query and props

<script setup>
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();   // reactive: current location
const router = useRouter(); // the router instance: navigate programmatically

// /tasks/42?tab=comments
route.params.id;    // '42' (always a string)
route.query.tab;    // 'comments'

function openTask(id) {
    router.push({ name: 'task', params: { id }, query: { tab: 'details' } });
}
</script>

Prefer passing params as props over reading useRoute() inside the view. It's easier to test and reusable outside the router. Remember that params are always strings. props: true hands the view id: '42', so if the component declares defineProps<{ id: number }>(), use the props function instead to convert it:

{ path: '/tasks/:id', name: 'task', component: TaskView, props: route => ({ id: Number(route.params.id) }) }

Reusing the component when params change

Going from /tasks/1 to /tasks/2 reuses the same TaskView instance, because it's the same route record. onMounted won't fire again. Watch the param instead:

const props = defineProps({ id: Number });
watch(() => props.id, (id) => loadTask(id), { immediate: true });

Navigation guards

Guards run before a navigation is confirmed. They can return:

  • nothing / true: continue
  • false: cancel the navigation
  • a route location ('/login' or { name: 'login', query: {...} }): redirect
// Global guard: protect anything with meta.requiresAuth
router.beforeEach((to, from) => {
    const auth = useAuthStore();
    if (to.meta.requiresAuth && !auth.isLoggedIn) {
        return { name: 'login', query: { redirect: to.fullPath } };
    }
});

// Per-route guard
{ path: '/admin', component: Admin, beforeEnter: (to) => isAdmin() || '/tasks' }

Inside components, onBeforeRouteLeave is perfect for "you have unsaved changes":

import { onBeforeRouteLeave } from 'vue-router';

onBeforeRouteLeave(() => {
    if (form.dirty && !window.confirm('Discard unsaved changes?')) return false;
});

Try it: a router in miniature

The demo below is a small router built from scratch: a reactive currentPath, a route table with :param patterns, a beforeEach guard and a <RouterView>-style dynamic component. Real Vue Router works the same way, plus history.pushState. Try opening Settings while logged out, then log in and try again. The log shows each guard decision.

The heart of it is very small:

const currentPath = ref('/tasks');

const matched = computed(() => {
    for (const route of routes) {
        const params = matchPath(route.path, currentPath.value); // '/tasks/:id' vs '/tasks/2'
        if (params) return { route, params };
    }
    return { route: notFound, params: {} };
});

function push(to) {
    const target = matchRoute(to);
    const result = beforeEach(target, matched.value);
    if (result === false) return;                 // cancelled
    if (typeof result === 'string') return push(result); // redirected
    currentPath.value = to;                       // confirmed: RouterView re-renders
}

Fetching data for a route

There are two common strategies:

Strategy How UX
Fetch after navigation Navigate immediately, show a skeleton, load in the component Fast feedback. Most common.
Fetch before navigation Load in a guard / beforeRouteEnter, navigate when ready No empty state, but the click feels slower. Pair it with a progress bar.

A robust "fetch after navigation" view handles loading, error, empty and success states, plus stale responses when the user clicks quickly between tasks:

<script setup>
import { ref, watch } from 'vue';

const props = defineProps({ id: Number });
const task = ref(null);
const loading = ref(false);
const error = ref(null);

watch(() => props.id, async (id, _old, onCleanup) => {
    let stale = false;
    onCleanup(() => { stale = true; });

    loading.value = true;
    error.value = null;
    try {
        const { data } = await axios.get(`/api/tasks/${id}`);
        if (!stale) task.value = data.data;
    } catch (e) {
        if (!stale) error.value = e.response?.status === 404 ? 'Task not found' : 'Could not load task';
    } finally {
        if (!stale) loading.value = false;
    }
}, { immediate: true });
</script>

<template>
    <div v-if="loading" class="skeleton" />
    <p v-else-if="error" class="error">{{ error }} <button @click="$router.back()">Back</button></p>
    <article v-else-if="task">
        <h1>{{ task.title }}</h1>
    </article>
</template>

This demo simulates the API with random latency (300–1500 ms). Task #4 returns a 404. Click quickly between tasks: thanks to the stale check, the detail panel always matches the selected task.

Don't forget the server: client-side guards are a UX feature, not security. Anyone can call your API directly, so every protected Laravel endpoint must still check authentication and authorisation (middleware + policies).

Key takeaways

  • Vue Router maps URLs to components and uses pushState so navigation never reloads the page. Web history mode needs a server catch-all.
  • Use named routes, :params (with regex constraints) and props to keep views decoupled from the router.
  • Lazy routes (() => import(...)) split your bundle per page.
  • Guards (beforeEach, beforeEnter, onBeforeRouteLeave) can continue, cancel or redirect. Use meta for declarative rules like requiresAuth.
  • When a param changes, the component is reused, so watch the param. Handle loading, error and stale responses explicitly.
  • Client guards improve UX. Real security lives in the Laravel API.