DEV Community

Roya
Roya

Posted on

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

Vue is great. Vite is great. Vue Router is great. But there's one area where the "bring your own solution" philosophy starts to show its cracks: navigation guards.

The moment your app needs more than a simple auth check — analytics, permission levels, feature flags, prefetching — you start piling logic into beforeEach and hoping for the best.


What about the existing solutions?

To be fair, there are some community approaches out there:

  • Roll your own middleware runner — works, but you're reinventing the wheel every project, with no type safety
  • vue-router-middleware-plugin — closest thing I found, but no TypeScript support for middleware names, no async context handling
  • Just use Nuxt — valid option, but sometimes you really don't need the full SSR framework for a simple SPA

None of these fully scratched the itch, so I built vite-plugin-vue-middleware.

The goal was simple: make Vue SPA middleware feel as natural as Nuxt, without leaving the Vite ecosystem.

What started as a straightforward file-scanning plugin turned into something more interesting, especially when I ran headfirst into the async context problem — more on that below.


What problems it solves

Problem 1: Everything piles up in beforeEach

Fix: Each middleware is its own file inside src/middleware/. The plugin scans the directory automatically and wires everything up.

src/middleware/
├── 01.auth.global.ts     ← runs on every route, first
├── 02.log.global.ts      ← runs on every route, second
├── admin.ts              ← only runs when the route opts in
└── guest.ts
Enter fullscreen mode Exit fullscreen mode

Your router setup becomes a single line:

import { setupMiddleware } from 'virtual:vue-middleware'
setupMiddleware(router)
Enter fullscreen mode Exit fullscreen mode

That's it. New middleware file = automatically picked up. Delete a file = gone. No manual registration, no imports to remember.

Problem 2: No type safety for named middleware

When you write middleware names as strings in meta.middleware, there's no autocomplete, no error on typos — you only find out at runtime that 'auht' is not a valid middleware name. Classic.

Fix: The plugin auto-generates a .d.ts that augments vue-router's RouteMeta, so your middleware names are fully typed with IntelliSense.

definePage({
  meta: {
    middleware: ['admin', 'guest'], // autocomplete works, typos caught at compile time
  },
})
Enter fullscreen mode Exit fullscreen mode

Problem 3: inject() silently breaks after await — and TanStack Query will find this out for you

This one caught me off guard. Vue's inject() only works inside an active app context. In an async function, code after await runs in a new microtask — Vue's context is already gone by then.

Why does TanStack Vue Query come into this? Because useQueryClient() calls inject() under the hood. So if you ever try to do something like this in middleware:

// ❌ looks fine, blows up at runtime
export default defineMiddleware(async (to) => {
  await validateSession()
  const queryClient = useQueryClient() // inject() called outside of component setup 💥
  await queryClient.prefetchQuery(userQueryOptions)
})
Enter fullscreen mode Exit fullscreen mode

You get a cryptic error, scratch your head for 20 minutes, and eventually realize it's not TanStack's fault — it's the async context evaporating after await.

Fix: The plugin applies a build-time AST transform that converts your async middleware into a generator-based executor. Each segment after an await is re-entered via app.runWithContext(), which restores the Vue injection context automatically.

The transform is completely transparent — you keep writing normal async/await, the plugin handles the rest:

// ✅ works — inject() (and useQueryClient) are available after every await
export default defineMiddleware(async (to) => {
  await validateSession()
  const queryClient = useQueryClient() // works perfectly now
  await queryClient.prefetchQuery(userQueryOptions)
})
Enter fullscreen mode Exit fullscreen mode

No manual changes needed on your end. Just write middleware like you normally would.


Quick start

npm install -D vite-plugin-vue-middleware
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts
import vueMiddleware from 'vite-plugin-vue-middleware'

export default defineConfig({
  plugins: [vue(), vueMiddleware()],
})
Enter fullscreen mode Exit fullscreen mode
// src/router/index.ts
import { setupMiddleware } from 'virtual:vue-middleware'
setupMiddleware(router)
Enter fullscreen mode Exit fullscreen mode
// src/middleware/01.auth.global.ts
import { defineMiddleware } from 'virtual:vue-middleware'

export default defineMiddleware(async (to) => {
  const isLoggedIn = await checkAuth()
  if (!isLoggedIn && to.path !== '/login') return '/login'
})
Enter fullscreen mode Exit fullscreen mode

Return values follow the same contract as vue-router navigation guards:

  • nothing returned → continue
  • return false → abort navigation
  • return '/path' or return { name: 'login' } → redirect

File naming conventions

Filename Behavior
auth.global.ts Runs on every route navigation
01.auth.global.ts Global, numeric prefix controls execution order
admin.ts Only runs when meta.middleware includes 'admin'

Works with unplugin-vue-router

If you're using file-based routing with unplugin-vue-router, you can declare middleware directly in your .vue files:

<script setup lang="ts">
definePage({
  meta: {
    middleware: ['auth', 'admin'],
  },
})
</script>
Enter fullscreen mode Exit fullscreen mode

Full type safety included.


Try it out

If you run into any issues or have feature ideas, issues and PRs are very welcome. Would love to hear if you've hit the async context problem too — curious how common it actually is.

If this saves you some headache, a ⭐ on GitHub would mean a lot — it helps others find the project too!

Top comments (0)