~/ blog/ vue-router-citadel-structured-middleware-layer-2026

Vue Router Citadel — the structured middleware layer for Vue Router I was missing

Originally published in Ukrainian on DOU in March 2026.

🏗️ GitHub · 📦 npm · 📖 Documentation

This article is for frontend developers who work in the Vue ecosystem and have run into the limits of navigation guards in large applications.

The goal is to share experience and a tool for a problem that is harder than it looks, and to invite the community into open-source collaboration. The article covers the main concepts and capabilities of the library without duplicating the documentation. If it sparks your interest, the documentation has detailed explanations, code examples and diagrams.

The problem

Navigation guards in Vue Router work well in small applications, where there are few of them and the logic is simple. But in projects that scale — especially modular SPAs with dozens of routes — navigation logic gradually turns into a policy system of its own.

Checks pile up: access (RBAC), subscriptions, feature flags, account state, KYC, MFA, SSO, data preloading. Each one is implemented as a guard — often independently of the others, by different developers, at different times. Vue Router 4 removed the pain of the next() callback with a return-based API, but the architectural problems remained.

Implicit execution order. Every module registers its own beforeEach, and the overall execution picture becomes opaque. The order depends on when a guard was registered, not on an explicit priority. Some checks run globally, some at the route level, some inside components (beforeRouteEnter, beforeRouteLeave). How they interact is not always obvious, and there is no single place where you can see the full navigation chain.

No route scoping. Vue Router has no built-in mechanism for route-scoped middleware. It is usually done with to.meta checks inside a global beforeEach — which leads to growing conditionals, duplicated checks and tight coupling between route configuration and guard implementation.

Dynamic management. In a modular application, each module has to register its guards on initialization, bind them to routes, and remove them on deactivation — independently of other modules. The standard API lets you remove a beforeEach, but coordinating this by hand in a growing system quickly gets fragile. And if a guard needs heavy dependencies, there is no built-in way to load it lazily.

Observability and error handling. When navigation is blocked or redirected, it is often hard to tell which guard caused it. There is no centralized logging, no single error-handling mechanism, and no control over how long async checks may take.

As the application grows, navigation guards stop being simple “checks before a transition” — they become a policy layer. But Vue Router gives you no tools to manage that policy system as a whole.

What it looks like in code

The example below uses next() from the Vue Router 3 days — with the return-based API in Vue Router 4 the problems look exactly the same. A typical project after a year of development by several people:

// guards/maintenance.ts — developer A, January
router.beforeEach((to, from, next) => {
  const isMaintenanceMode = localStorage.getItem('maintenance') === 'true';

  if (isMaintenanceMode) {
    next(false);

    return;
  }

  next();
});

// guards/auth.ts — developer B, February
router.beforeEach(async (to, from, next) => {
  const isAuthenticated = Boolean(localStorage.getItem('token'));

  if (to.meta.requiresAuth && !isAuthenticated) {
    next({ name: 'login' });

    return;
  }

  next();
});

// guards/permissions.ts — developer C, March
router.beforeEach(async (to, from, next) => {
  if (to.meta.roles) {
    const user = await fetchUser();

    if (!to.meta.roles.includes(user.role)) {
      next({ name: 'forbidden' });

      return;
    }
  }

  next();
});

// guards/feature-flags.ts — developer D, six months later
router.beforeEach((to, from, next) => {
  if (to.meta.feature && !isFeatureEnabled(to.meta.feature)) {
    next({ name: 'not-found' });

    return;
  }

  next();
});

Four files, four beforeEach calls, four developers, zero coordination.

The road to a solution

I solved these problems on every project. On the first one I built a lot of groundwork — wrappers around beforeEach, file conventions, execution order. On the next projects I carried it over and kept improving it. Over 4 years and 4 large projects — fintech platforms with 50–80+ routes, dozens of guards, RBAC plus subscriptions plus feature flags, frontend teams of 2–8 developers — the approaches were refined, and ideas for features appeared that never got implemented in internal code. But it always stayed part of a specific project: a slightly different API, a slightly different structure, adapted to the context. Only by building a standalone package did I manage to take all of that work and those ideas to a finished implementation.

I searched npm and looked at existing libraries — their APIs, usage examples, source code. I did not find a middleware library that was minimal, declarative and scalable at the same time. Most of them only solve route-scoped middleware and share the same problem: they attach a direct reference to the handler in the route configuration via route.meta. That creates tight coupling between the route definition and a specific guard implementation. No global guards, no priorities, no dynamic management at runtime — let alone lazy loading of heavy handlers or code splitting for guards. A detailed comparison with the alternatives is on the Vue Router Middleware Comparison page of the documentation.

Of course, you can write your own middleware layer on router.beforeEach and route.meta — it is literally 20 lines of code. Then you add error handling. Then priorities. Then scopes. Then logging. Then typing. And before you know it, you have written your own library. That is exactly where I ended up.

When I started a new side project, I finally decided to turn it all into a standalone library. I reworked the accumulated approaches, added what was missing, and shipped it with full documentation and tests.

Here is how I addressed the main shortcomings of existing libraries: a route only references a middleware by name, and the middleware itself is registered centrally. The route knows it needs 'billing:premium', but it does not know — and should not know — where or how that outpost is implemented. This frees up dynamic management and, as a bonus, made type-safe naming possible through declaration merging: TypeScript suggests the available outpost names in the IDE and validates them at compile time.

Vue Router Citadel

Vue Router Citadel is a navigation policy management layer for Vue Router 4 and 5. It does not replace router guards — it is a thin abstraction on top of the existing beforeEach / beforeResolve / afterEach that adds structure once guards turn into a policy system. It is built for applications where standard guards are no longer enough:

  • Access control — RBAC systems, permission gates, admin areas, multi-tenant setups with subscription and feature-flag validation
  • Modular architecture — type-safe declarations per module, dynamic management of outposts at runtime
  • Complex navigation logic — SSO, MFA, session refresh, data preloading

Vue Router already uses the term guards. I wanted a concept that naturally extends that idea, and it grew into a whole set of names and relationships:

  • Citadel — the protective layer over the router, the entry point and control center
  • Outposts — individual guard checkpoints along the route, each with its own area of responsibility
  • Verdicts — the decision of each outpost: ALLOW, BLOCK, or a redirect
  • Scope — where an outpost applies: global (every navigation) or route (specific pages only)
  • Deploy / Abandon — rolling outposts out and taking them down
  • Assign / Revoke — binding and unbinding route-scoped outposts to specific routes at runtime
  • Patrol — the sequential pass over outposts during navigation: each outpost is checked in turn until one stops the patrol with its verdict

The names may seem unusual, but they give a clear mental model: the citadel protects the application, outposts stand along the routes, and each one delivers its verdict.

Here is what a full setup looks like:

import { createRouter, createWebHistory } from 'vue-router';
import { createNavigationCitadel } from 'vue-router-citadel';
import { createApp } from 'vue';
import App from './App.vue';

// 1. Create the router and routes
const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('./pages/Home.vue'),
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('./pages/Login.vue'),
  },
  {
    path: '/dashboard',
    name: 'dashboard',
    component: () => import('./pages/Dashboard.vue'),
    meta: { requiresAuth: true },
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

// 2. Create the citadel and outposts
const outposts = [
  {
    name: 'maintenance',
    priority: 1,
    handler: ({ verdicts }) => {
      const isMaintenanceMode = localStorage.getItem('maintenance') === 'true';

      if (isMaintenanceMode) {
        return verdicts.BLOCK;
      }

      return verdicts.ALLOW;
    },
  },
  {
    name: 'auth',
    priority: 10,
    handler: ({ verdicts, to }) => {
      const isAuthenticated = Boolean(localStorage.getItem('token'));

      if (to.meta.requiresAuth && !isAuthenticated) {
        return { name: 'login', query: { redirect: to.fullPath } };
      }

      return verdicts.ALLOW;
    },
  },
];

const citadel = createNavigationCitadel(router, {
  outposts,
});

// 3. Install as a Vue plugin
const app = createApp(App);

app.use(router);
app.use(citadel);
app.mount('#app');

Each outpost is a configuration object: name, priority, scope, handler. The handler returns a verdict. Execution order is explicit through priority. Everything is in one place, yet each outpost is a separate unit with its own area of responsibility.

But the main point is not just the declarative setup. Outposts can be added, removed and re-bound to routes at runtime — exactly what a modular monolith architecture needs. Each module registers its outposts on initialization and removes them on deactivation, independently of other modules, through a single API:

// modules/billing/index.ts
import { NavigationOutpostScopes, type NavigationCitadelAPI } from 'vue-router-citadel';
import { premiumHandler, trialHandler } from './outposts';

// Recommended convention: 'module:action' — avoids name collisions between modules
const outpostNames = ['billing:premium', 'billing:trial-expired'] as const;

export function registerBillingModule(citadel: NavigationCitadelAPI) {
  // Register outposts
  citadel.deployOutpost([
    {
      scope: NavigationOutpostScopes.ROUTE,
      name: 'billing:premium',
      handler: premiumHandler,
    },
    {
      scope: NavigationOutpostScopes.ROUTE,
      name: 'billing:trial-expired',
      handler: trialHandler,
    },
  ]);

  // Bind outposts to routes
  citadel.assignOutpostToRoute('billing-settings', ['billing:premium']);
  citadel.assignOutpostToRoute('billing-plans', ['billing:trial-expired']);
}

export function unregisterBillingModule(citadel: NavigationCitadelAPI) {
  // Unbind from routes
  citadel.revokeOutpostFromRoute('billing-settings', ['billing:premium']);
  citadel.revokeOutpostFromRoute('billing-plans', ['billing:trial-expired']);

  // Remove outposts
  citadel.abandonOutpost(NavigationOutpostScopes.ROUTE, [...outpostNames]);
}

For heavy handlers there are lazy outposts — the module is loaded via dynamic import on the first navigation and then cached:

citadel.deployOutpost({
  name: 'heavy-validation',
  lazy: true,
  handler: () => import('./outposts/heavy-validation'),
});

The handler pulls in its dependencies only when it is actually needed — not at application startup.

Outposts from production patterns

The global outposts above (maintenance, auth) are only part of the picture. On real projects, most of the complexity is in route-scoped outposts that run only on the pages they are bound to. Here are a few patterns abstracted from fintech production projects:

// RBAC — role check via route meta
citadel.deployOutpost({
  scope: 'route',
  name: 'role-access',
  priority: 20,
  handler: ({ verdicts, to }) => {
    const user = useAuth().user();

    if (user.role !== to.meta.role) {
      return { name: 'forbidden' };
    }

    return verdicts.ALLOW;
  },
});

// Onboarding — redirect if registration is not complete
citadel.deployOutpost({
  scope: 'route',
  name: 'onboarding-complete',
  handler: ({ verdicts }) => {
    if (!useAuth().user().completedRegistration) {
      return { name: 'onboarding' };
    }

    return verdicts.ALLOW;
  },
});

// Payment validation — check that the payment method is supported
citadel.deployOutpost({
  scope: 'route',
  name: 'payment-supported',
  handler: ({ verdicts, to }) => {
    const settings = useTransactionSettings();

    if (!settings.hasPaymentMethod(to.meta.paymentMethod)) {
      return to.meta.redirect ?? { name: 'dashboard' };
    }

    return verdicts.ALLOW;
  },
});

// Account completeness — async check that loads data from a store
citadel.deployOutpost({
  scope: 'route',
  name: 'account-setup',
  handler: async ({ verdicts }) => {
    const accountStore = useAccountStore();

    await accountStore.loadChecks();

    if (accountStore.isBlocked) {
      return { name: 'account-completeness' };
    }

    return verdicts.ALLOW;
  },
});

Routes reference outposts by name — without knowing anything about their implementation:

const routes = [
  {
    path: '/admin',
    name: 'admin',
    component: () => import('./pages/Admin.vue'),
    meta: { outposts: ['role-access'], role: 'admin' },
  },
  {
    path: '/account',
    name: 'account',
    component: () => import('./pages/Account.vue'),
    meta: { outposts: ['onboarding-complete', 'account-setup'] },
  },
  {
    path: '/payment/card',
    name: 'payment-card',
    component: () => import('./pages/PaymentCard.vue'),
    meta: {
      outposts: ['payment-supported'],
      paymentMethod: 'card',
      redirect: { name: 'payment-methods' },
    },
  },
];

Sync and async handlers, meta-driven configuration, fallback redirects through meta.redirect — patterns that repeat in every large project.

Key capabilities

Verdict system

Instead of next() and its variations — a single way to return a result from a handler:

  • verdicts.ALLOW — navigation continues
  • verdicts.BLOCK — navigation is cancelled
  • { name: 'login' } — redirect to a named route
  • { path: '/error' } — redirect to a path
  • '/path' — redirect (string)
  • throw Error — caught by the onError handler

Redirect targets are validated through the router — if the route does not exist, the citadel throws an error instead of silently redirecting into nowhere. Clean control flow: a handler is a function that takes a context and returns a verdict. Easy to read, easy to test.

Outpost scopes

Two scopes define when an outpost runs:

  • Global — runs on every navigation. Auth, maintenance, analytics.
  • Route — runs only when bound to a route through meta.outposts.

Processing is sequential: first all global outposts by priority, then route outposts by priority. If any of them returns BLOCK or a redirect, navigation stops and the remaining outposts are skipped.

With nested routes, outposts are collected from all matched routes (parent and children) and deduplicated automatically. Duplicates are removed with a console warning — shared outposts are best placed on the parent route only:

const routes = [
  {
    path: '/account',
    component: AccountLayout,
    meta: { outposts: ['auth'] },
    children: [
      {
        path: 'billing',
        name: 'account-billing',
        component: Billing,
        meta: { outposts: ['verified', 'premium'] }, // auth + verified + premium
      },
    ],
  },
];

Priority-based execution

A numeric priority defines the execution order of outposts within each scope. Lower value — earlier execution. The default is 100.

citadel.deployOutpost([
  { name: 'analytics', handler: analyticsHandler, priority: 200 },
  { name: 'auth', handler: authHandler, priority: 10 },
  { name: 'permissions', handler: permHandler, priority: 50 },
]);

// Execution order: auth (10) → permissions (50) → analytics (200)

Sorting happens on deploy, not during navigation — zero runtime overhead.

An outpost can run at any stage of the Vue Router navigation cycle:

  • BEFORE_EACH — before navigation, can block (auth, permissions)
  • BEFORE_RESOLVE — after async components resolve, can block (data validation)
  • AFTER_EACH — after navigation, does not block (analytics, logging)

By default an outpost uses beforeEach. One outpost can handle several hooks:

import { NavigationHooks } from 'vue-router-citadel';

citadel.deployOutpost({
  name: 'admin-only',
  hooks: [NavigationHooks.BEFORE_RESOLVE, NavigationHooks.AFTER_EACH],
  handler: ({ verdicts, hook }) => {
    switch (hook) {
      case NavigationHooks.BEFORE_RESOLVE: {
        // Role check
        return verdicts.ALLOW;
      }
      case NavigationHooks.AFTER_EACH: {
        // Log the visit
        return verdicts.ALLOW;
      }
      default: {
        return verdicts.ALLOW;
      }
    }
  },
});

Timeout control

A global timeout is set with defaultTimeout. Each outpost can override it or turn it off:

const citadel = createNavigationCitadel(router, {
  defaultTimeout: 5000,
  onTimeout: (outpostName, ctx) => {
    return { name: 'error' };
  },
});

// Per-outpost override: 30 seconds instead of the default 5
citadel.deployOutpost({
  name: 'data-loader',
  timeout: 30000,
  handler: async ({ verdicts }) => {
    await loadHeavyData();

    return verdicts.ALLOW;
  },
});

An outpost without an explicit timeout uses defaultTimeout. If neither is set, there is no timeout. A value of 0 disables the timeout explicitly — useful for afterEach outposts, where a timeout makes no sense (they do not block navigation anyway). For lazy outposts, the timeout covers only the handler execution — module loading is not limited.

Error handling

Centralized error handling through onError and onTimeout. If a handler throws, the citadel catches the error and passes it to onError, which returns a verdict (a redirect, BLOCK, or even ALLOW):

const citadel = createNavigationCitadel(router, {
  onError: (error, ctx) => {
    console.error('Navigation error:', error);

    return { name: 'error', query: { message: error.message } };
  },
});

onError has a default handler: the error is logged and navigation is blocked. Errors in afterEach outposts are logged but do not affect navigation — the page has already rendered.

Type-safe outpost names

Declaration merging gives autocomplete and compile-time validation of outpost names:

// src/outposts.d.ts
declare module 'vue-router-citadel' {
  interface GlobalOutpostRegistry {
    auth: true;
    maintenance: true;
  }

  interface RouteOutpostRegistry {
    'admin-only': true;
    'premium': true;
  }
}

After that, TypeScript validates names everywhere — in deployOutpost, abandonOutpost, route.meta.outposts:

citadel.abandonOutpost('global', 'auth'); // ✓
citadel.abandonOutpost('global', 'admin-only'); // ✗ Error: not a global outpost

If the registries are not extended, names stay string — type safety is optional.

Vue DevTools

A custom inspector for monitoring outposts in real time. It is enabled automatically in development on app.use(citadel).

It shows the outpost tree by scope, priorities, hooks and timeouts. The Route Assignments section shows which routes have outposts bound through meta.outposts. The Current Route section shows exactly which outposts will run on the current page — in the order they will be patrolled. It updates automatically on navigation. A settings panel lets you switch the log level and debug mode right from DevTools.

DevTools is loaded through a dynamic import — when disabled, its code does not end up in the production bundle.

Observability

When navigation is blocked, you need to understand why — fast. The citadel logs the whole chain: the start of navigation, each outpost in turn, and exactly which outpost stopped the patrol:

🔵 [🏰 NavigationCitadel] beforeEach: / → /dashboard (3 outposts)
🔵 [🏰 NavigationCitadel] Processing outpost 1/3: "maintenance" [beforeEach]
🔵 [🏰 NavigationCitadel] Processing outpost 2/3: "auth" [beforeEach]
🟡 [🏰 NavigationCitadel] Patrol stopped by outpost "auth": redirect → /login

Logging is enabled with log: true (on by default in development). Critical events — errors, timeouts, invalid routes — are always logged, regardless of settings. The log level and debug mode can also be switched from the Vue DevTools panel, without code changes or a reload.

For deeper debugging there is debug: true. It activates named debug points at key processing stages: navigation-start, outpost-enter, outpost-block, outpost-timeout, error-catch. Each debug point calls debugger;, so you can pause execution and inspect state right in the browser:

const citadel = createNavigationCitadel(router, {
  debug: true,
  // Custom debug handler instead of the default debugger;
  debugHandler: (pointName) => {
    console.trace(`Debug point: ${pointName}`);
    debugger;
  },
});

Both logger and debugHandler can be replaced with custom implementations — which lets you integrate the citadel with any observability stack (Datadog, Sentry, your own backend):

const citadel = createNavigationCitadel(router, {
  logger: {
    info: (...args) => apm.log('info', ...args),
    warn: (...args) => apm.log('warn', ...args),
    error: (...args) => apm.captureError(...args),
    debug: (...args) => apm.log('debug', ...args),
  },
});

Together with centralized error handling (onError, onTimeout) and the Vue DevTools inspector, this gives you the full picture of what is happening in the navigation layer.

Bundle size & compatibility

Core bundle — ≤ 4 KB (minified + brotli). The DevTools integration is not included and is tree-shaken out in production. ESM-first with a CJS fallback. TypeScript strict mode, compatible with Vue Router 4 and 5.

Trade-offs

Honestly, about the limitations:

  • An extra abstraction layer — one more layer between your code and Vue Router. For a project with 2–3 guards it is overkill.
  • Learning curve — the naming (citadel, outpost, verdict, patrol) takes getting used to. The metaphor helps after the first day, but at first it may feel unusual.
  • Not for every project — if your guards do not grow or change, a plain beforeEach is simpler and more direct.

A few questions that may come up:

  • Performance overhead — sorting happens on deploy (once), not during navigation. At runtime it is a pass over an array of functions with O(1) registry lookup. The overhead is effectively zero.
  • Testability — an outpost handler is a function that takes a context and returns a verdict. It is tested like any pure function — no router, no components, no DOM.
  • SSR — the library works in a server environment. A custom logger lets you replace console with a server logger (Pino, Bunyan). DevTools is disabled automatically outside the browser.

Summary

This is not a silver bullet — for a simple project with a couple of navigation guards, the standard approach is more than enough. But if your guards grow together with the project, structure saves you.

If the navigation logic in your application has become a policy system, it may be time to separate it from the router configuration.

npm install vue-router-citadel

🏗️ GitHub · 📦 npm · 📖 Documentation

Place guards at the gates. Outposts along the way.