← Back to Blog
TypeScript2026-05-10Β·73 min read

I built a Vite plugin to bring Nuxt-style middleware to Vue

By Roya

Architecting Scalable Route Guards in Vue SPAs: Beyond beforeEach

Current Situation Analysis

Vue Router's navigation guard system is intentionally minimal. For basic authentication checks, a single beforeEach hook is sufficient. However, modern single-page applications rarely stop at simple auth. As applications mature, routing logic accumulates analytics tracking, role-based access control, feature flag evaluation, route prefetching, and telemetry initialization. When all of this logic funnels into a single beforeEach callback, the routing layer becomes a monolithic bottleneck that is difficult to test, debug, and maintain.

The core issue stems from Vue's "bring your own solution" philosophy. While this grants flexibility, it leaves developers without a standardized pattern for composing routing logic. Many teams resort to ad-hoc middleware runners, string-based route metadata, or manual guard registration arrays. These approaches introduce three critical weaknesses:

  1. Execution Order Ambiguity: Without a deterministic loading strategy, guards run in unpredictable sequences, causing race conditions between auth validation and data prefetching.
  2. Type Safety Gaps: String-based route metadata (meta: { guards: ['auth', 'admin'] }) bypasses TypeScript's compiler. Typos and missing guard definitions only surface at runtime.
  3. Async Context Evaporation: Vue 3's dependency injection system (inject()) relies on an active component or app context. When a guard uses await, the subsequent code executes in a new microtask where the original Vue context has already detached. This silently breaks libraries that rely on inject() under the hood, such as TanStack Query or custom composable stores.

These problems are frequently overlooked because they manifest gradually. A team might start with three guards in beforeEach, add a fourth for analytics, then a fifth for prefetching. By the time the routing layer becomes unmanageable, refactoring requires rewriting navigation logic across the entire codebase. The absence of a file-based, type-safe, and async-context-aware routing pipeline forces developers to either accept technical debt or migrate to heavier frameworks like Nuxt, even when SSR is unnecessary.

WOW Moment: Key Findings

When evaluating routing architecture strategies, the trade-offs between manual guard registration, community plugins, and AST-driven file-based pipelines become starkly visible. The following comparison isolates the critical dimensions that determine long-term maintainability:

Approach Type Safety Async Context Preservation Maintenance Overhead Framework Dependency
Inline beforeEach Low (manual checks) None (context lost after await) High (monolithic callback) None
Community Middleware Plugin Medium (string-based meta) Partial (requires manual workarounds) Medium (manual registration) Low
AST-Driven File Pipeline High (auto-generated .d.ts) Full (automatic app.runWithContext() restoration) Low (convention over configuration) Low

The AST-driven file pipeline eliminates the async context problem entirely by transforming async/await syntax into a generator-based executor at build time. Each segment following an await is automatically re-entered through Vue's app.runWithContext(), restoring the injection scope without developer intervention. This enables seamless usage of inject()-dependent libraries inside routing guards, a capability that most existing solutions lack.

More importantly, file-based conventions shift routing logic from imperative registration to declarative organization. Adding a new guard is as simple as creating a file. Removing one is deleting it. The build tool handles wiring, ordering, and type generation. This reduces cognitive load, prevents registration drift, and aligns routing architecture with modern Vue ecosystem patterns.

Core Solution

Building a production-ready routing pipeline requires three coordinated layers: file discovery, AST transformation, and type augmentation. The implementation leverages Vite's plugin architecture to scan a dedicated directory, generate a virtual module, and rewrite async guard functions at compile time.

Step 1: Directory Structure & Naming Conventions

Establish a predictable file layout that communicates execution scope and priority:

src/guard-pipeline/
β”œβ”€β”€ 01.auth.global.ts      # Runs on every navigation, highest priority
β”œβ”€β”€ 02.telemetry.global.ts # Runs on every navigation, second priority
β”œβ”€β”€ admin.access.ts        # Route-specific, triggered via meta
└── prefetch.data.ts       # Route-specific, triggered via meta

The .global.ts suffix signals universal execution. Numeric prefixes enforce deterministic ordering without manual arrays. Plain filenames indicate route-scoped guards that only activate when explicitly referenced in route metadata.

Step 2: Vite Plugin & Virtual Module Generation

The plugin scans the directory during the configResolved hook, parses filenames, and generates a virtual module that exports a pipeline initializer. This decouples router configuration from guard registration:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import routeGuardPipeline from 'vite-plugin-route-guard-pipeline'

export default defineConfig({
  plugins: [
    vue(),
    routeGuardPipeline({
      dir: 'src/guard-pipeline',
      typesOutput: 'src/types/route-guards.d.ts',
    }),
  ],
})

The virtual module (virtual:route-guard-pipeline) exposes a single initialization function that attaches the compiled guard chain to the router instance.

Step 3: AST Transformation for Async Context Restoration

Vue 3's inject() fails after await because the microtask scheduler detaches the active app context. The plugin solves this by parsing guard files with a lightweight AST walker. When it detects an async function, it rewrites the body into a generator-based state machine. Each await becomes a yield point, and the plugin wraps subsequent execution blocks in app.runWithContext().

This transformation is transparent. Developers write standard async/await code:

// src/guard-pipeline/01.auth.global.ts
import { defineRouteGuard } from 'virtual:route-guard-pipeline'

export default defineRouteGuard(async (to) => {
  const session = await fetchSession()
  
  if (!session.valid && to.path !== '/login') {
    return '/login'
  }
})

At build time, this compiles to an equivalent structure that manually restores context after each suspension point. The developer never interacts with generators or context APIs directly.

Step 4: Type Augmentation for Route Metadata

String-based guard references are error-prone. The plugin generates a TypeScript declaration file that extends RouteMeta:

// src/types/route-guards.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    guards?: Array<'admin.access' | 'prefetch.data'>
  }
}

Route definitions now benefit from autocomplete and compile-time validation:

// src/router/routes.ts
import { defineRoute } from 'vue-router'

export const adminRoute = defineRoute({
  path: '/dashboard',
  component: () => import('@/views/Dashboard.vue'),
  meta: {
    guards: ['admin.access', 'prefetch.data'],
  },
})

Architecture Rationale

  • Why Vite over Webpack? Vite's native ESM pipeline and fast HMR make file scanning and virtual module generation significantly more efficient. The plugin hooks into transform and configResolved without requiring complex loader chains.
  • Why AST over Runtime Parsing? Runtime guard registration adds overhead to every navigation. Build-time transformation shifts the cost to compilation, resulting in zero runtime penalty for guard discovery.
  • Why Generators over Manual Context Wrapping? Manual app.runWithContext() calls are verbose and easily forgotten. AST rewriting enforces consistency across all guards while preserving developer ergonomics.

Pitfall Guide

1. Async Context Evaporation

Explanation: inject() relies on an active Vue app context. After await, the execution moves to a new microtask where the context is detached. Libraries like TanStack Query or Pinia stores that use inject() will throw silent or cryptic errors. Fix: Rely on the AST transform to automatically restore context. If writing custom guards without the plugin, manually wrap post-await logic in app.runWithContext(() => { ... }).

2. Silent Guard Failures

Explanation: Navigation guards that throw unhandled exceptions or fail to return a value can leave the router in a pending state, causing the UI to freeze on navigation. Fix: Always implement explicit return contracts. Return undefined to continue, false to abort, or a route location to redirect. Wrap guard logic in try/catch blocks with fallback redirects.

3. Execution Order Ambiguity

Explanation: File systems do not guarantee alphabetical or creation-time ordering. Relying on implicit load order causes race conditions between auth validation and data prefetching. Fix: Enforce numeric prefixes for global guards. Document the convention in team guidelines. Validate order during CI by parsing filenames and asserting sequence.

4. Type Augmentation Collisions

Explanation: Multiple plugins or manual declarations may attempt to extend RouteMeta, causing TypeScript to merge incompatible types or throw duplicate identifier errors. Fix: Centralize route metadata extensions in a single .d.ts file. Use module augmentation carefully and avoid re-declaring the same interface across multiple files.

5. Over-Scoping Route-Specific Guards

Explanation: Attaching multiple route-specific guards to a single route increases complexity and makes debugging difficult. Each guard runs independently, potentially causing conflicting redirects. Fix: Compose related logic into a single guard file. Use internal helper functions to separate concerns while maintaining a single execution entry point per route.

6. Circular Redirects

Explanation: An auth guard redirects unauthenticated users to /login, while a guest guard redirects authenticated users away from /login. If not carefully scoped, this creates an infinite navigation loop. Fix: Implement path exclusion checks. Auth guards should skip /login and /register. Guest guards should skip protected routes. Log redirect decisions during development to detect loops early.

7. Missing Error Boundaries

Explanation: Network failures or malformed responses inside a guard can crash the navigation pipeline. Without error handling, users encounter blank screens or stuck loaders. Fix: Wrap async operations in try/catch. Return a fallback route or abort navigation gracefully. Implement a global navigation error handler in the router to catch unhandled guard exceptions.

Production Bundle

Action Checklist

  • Install the Vite plugin and configure the guard directory path
  • Create the src/guard-pipeline/ directory with numeric prefixes for global guards
  • Verify the generated .d.ts file extends RouteMeta correctly
  • Test async guards with inject()-dependent libraries to confirm context restoration
  • Implement explicit return contracts (undefined, false, or location object)
  • Add path exclusion logic to prevent circular redirects
  • Configure a router-level onError handler to catch unhandled guard exceptions
  • Document naming conventions and execution order in the team wiki

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Simple SPA with auth only Inline beforeEach Low complexity, minimal setup Near zero
Growing SPA with multiple concerns AST-driven file pipeline Type safety, async context handling, maintainability Low (build-time only)
Enterprise app with SSR/SSG requirements Nuxt or Nitro-based routing Built-in middleware, server context, hydration High (framework migration)
Legacy Vue 2 project Manual guard runner with context polyfills Vue 2 lacks native app.runWithContext() Medium (runtime overhead)

Configuration Template

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import routeGuardPipeline from 'vite-plugin-route-guard-pipeline'

export default defineConfig({
  plugins: [
    vue(),
    routeGuardPipeline({
      dir: 'src/guard-pipeline',
      typesOutput: 'src/types/route-guards.d.ts',
      transformAsyncContext: true,
    }),
  ],
})
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { initializeGuardPipeline } from 'virtual:route-guard-pipeline'
import routes from './routes'

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

initializeGuardPipeline(router)

export default router
// src/guard-pipeline/01.auth.global.ts
import { defineRouteGuard } from 'virtual:route-guard-pipeline'
import { useAuthStore } from '@/stores/auth'

export default defineRouteGuard(async (to) => {
  const auth = useAuthStore()
  
  try {
    const isValid = await auth.validateSession()
    if (!isValid && to.path !== '/login') {
      return '/login'
    }
  } catch {
    return '/error/session-expired'
  }
})
// src/types/route-guards.d.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    guards?: Array<'admin.access' | 'prefetch.data' | 'analytics.track'>
  }
}

Quick Start Guide

  1. Install the plugin: Run npm install -D vite-plugin-route-guard-pipeline and add it to your Vite configuration alongside the Vue plugin.
  2. Create the pipeline directory: Set up src/guard-pipeline/ and add your first global guard with a numeric prefix (e.g., 01.auth.global.ts).
  3. Initialize in the router: Import initializeGuardPipeline from the virtual module and call it immediately after creating the router instance.
  4. Verify type generation: Check that the plugin outputs the .d.ts file and that your IDE provides autocomplete for guard names in meta.guards.
  5. Test async context: Add a guard that uses await followed by an inject()-dependent call. Confirm that no context errors occur during navigation.