Route Table

Every view is a dynamic import, so each route becomes its own chunk and the initial bundle stays small. Private routes are marked declaratively with meta: { auth: true }:

// client/src/router.ts
import { createRouter, createWebHistory } from "vue-router"
import { useAuthStore } from "~/stores/auth"

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/", component: () => import("~/views/Home.vue") },
    { path: "/login", component: () => import("~/views/Login.vue") },
    { path: "/register", component: () => import("~/views/Register.vue") },
    {
      path: "/catalogs",
      component: () => import("~/views/Catalogs.vue"),
      meta: { auth: true },
    },
    {
      path: "/books",
      component: () => import("~/views/Books.vue"),
      meta: { auth: true },
    },
  ],
})

// private routes require an authenticated session
router.beforeEach(to => {
  const auth = useAuthStore()
  if (to.meta.auth && !auth.isAuthenticated) {
    return { path: "/login", query: { redirect: to.fullPath } }
  }
  return true
})

export default router

Design Notes

  • One guard, declarative routes. Authorization intent lives on the route definition (meta.auth), not scattered across components. Adding a new private page is one flag.
  • Redirect preservation. The guard passes the intended destination as ?redirect=, so a successful login can return the user where they were headed.
  • Session, not permissions. The router only asks “is anyone logged in?” — fine-grained authorization (who may call books/add) is enforced server-side by the agent’s capability map. The client never becomes the security boundary.
  • Guard reads the store lazily. useAuthStore() is called inside the guard, after Pinia is installed — a subtle but standard ordering constraint.