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.

Syntaxconst 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

PathNameNeeds auth
/sign-insign-inno
/taskstasksyes
/tasks/newtask-newyes
/tasks/:idtask-detailyes

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

Example · vue
<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.

Example · ts
// 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.

Example · ts
// 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: 2

Typing 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.

Example · ts
// 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

  • Be the first to comment on this lesson.