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
Your router setup becomes a single line:
import { setupMiddleware } from 'virtual:vue-middleware'
setupMiddleware(router)
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
},
})
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)
})
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)
})
No manual changes needed on your end. Just write middleware like you normally would.
Quick start
npm install -D vite-plugin-vue-middleware
// vite.config.ts
import vueMiddleware from 'vite-plugin-vue-middleware'
export default defineConfig({
plugins: [vue(), vueMiddleware()],
})
// src/router/index.ts
import { setupMiddleware } from 'virtual:vue-middleware'
setupMiddleware(router)
// 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'
})
Return values follow the same contract as vue-router navigation guards:
- nothing returned → continue
-
return false→ abort navigation -
return '/path'orreturn { 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>
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)