What You Are Building
A task tracker with routing, sign-in, list and detail screens, forms and a cached API, with every route decided before any code.
const routes: RouteRecordRaw[] = [{ path, name, component, meta }]The capstone is one application assembled from the earlier stages. It is a task tracker: a sign-in screen, a list of tasks, a detail screen for a single task, create and edit forms, and an API layer that keeps what it has already fetched instead of refetching on every visit.
This lesson decides the shape. A route is a URL pattern paired with a component; the router swaps that component into <router-view> when the URL changes. Writing the route table before any screen exists forces you to name every screen and to mark which ones need a signed-in user. app.use(router) registers <router-view> and <router-link> for the whole application, so no component imports them.
The routes
| Path | Name | Needs auth |
|---|---|---|
/sign-in | sign-in | no |
/tasks | tasks | yes |
/tasks/new | task-new | yes |
/tasks/:id | task-detail | yes |
The data
One resource, four calls: GET /tasks, POST /tasks, PATCH /tasks/:id, DELETE /tasks/:id. One type, Task, and one narrower type for what a form submits.
What trips people
Deciding URLs late. A screen reached by a button rather than a URL cannot be linked, bookmarked or deep-linked into by a test, and retrofitting the URL means rewriting the component that owned the state.
Example
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
<template>
<header>
<nav>
<router-link :to="{ name: 'tasks' }">Tasks</router-link>
<router-link :to="{ name: 'task-new' }">New task</router-link>
</nav>
<p v-if="auth.user">{{ auth.user.email }}</p>
<router-link v-else :to="{ name: 'sign-in' }">Sign in</router-link>
</header>
<main>
<router-view />
</main>
</template>
<style scoped>
header {
display: flex;
justify-content: space-between;
padding: 0.75rem 1rem;
}
nav {
display: flex;
gap: 1rem;
}
</style>When to use it
- Agreeing the URL for every screen before the first component is written.
- Marking which routes need a signed-in user so one guard covers them all.
- Deriving the form's type from the resource type so the two cannot drift apart.
- Listing the four API calls the application makes, so the API layer has a fixed surface.
More examples
The route table, written first
Every screen in the application, with the component loaded by a dynamic import so each route becomes its own chunk. The catch-all pattern must be written as :pathMatch(.*)* for Vue Router to capture the unmatched segments. Reach for this before writing a single view: the table is the specification.
// src/router/routes.ts
import type { RouteRecordRaw } from 'vue-router'
export const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/tasks' },
{
path: '/sign-in',
name: 'sign-in',
component: () => import('@/views/SignInView.vue'),
},
{
path: '/tasks',
name: 'tasks',
component: () => import('@/views/TasksView.vue'),
meta: { requiresAuth: true },
},
{
path: '/tasks/new',
name: 'task-new',
component: () => import('@/views/TaskNewView.vue'),
meta: { requiresAuth: true },
},
{
path: '/tasks/:id',
name: 'task-detail',
component: () => import('@/views/TaskDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
},
]The data shape, written second
One interface for what the server returns and one derived type for what a form sends. TaskDraft is built with Pick, so adding a field to Task cannot leave the form type behind. Reach for a derived type whenever a form submits a subset of a resource.
// src/types/task.ts
export interface Task {
id: string
title: string
notes: string
done: boolean
updatedAt: string
}
export type TaskDraft = Pick<Task, 'title' | 'notes'>
// A draft is a Task minus the server's fields:
// Object.keys({ title: '', notes: '' }).length // Output: 2Typing route meta and guarding the private routes
declare module 'vue-router' widens the library's RouteMeta interface so requiresAuth is a known boolean instead of unknown. The guard returns a location object, which Vue Router treats as a redirect, and stashes the original URL in a query parameter so sign-in can send the user back. createWebHistory takes import.meta.env.BASE_URL so the app still works when it is deployed under a sub-path.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'
import { useAuthStore } from '@/stores/auth'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
}
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.user) {
return { name: 'sign-in', query: { next: to.fullPath } }
}
})
export default router
Discussion