Vue.js 60 🟢 Typed Routing and Automated Data Fetching at Router Level
A Vue Router configuration has two persistent weaknesses as an application grows. The first is that route names and parameters are strings. A typo in router.push({ name: 'user-profile' }) when the route is actually named 'userProfile' compiles without error and fails at runtime. The second is that data fetching lives inside the component’s onMounted hook, which means the component renders with empty state, the navigation completes before the data arrives, and the user sees a loading spinner on every visit. Both weaknesses have solutions that operate at the router level: typed routes validate route names and parameters at compile time, and data loaders move the fetching logic outside the component so it can be awaited before navigation completes.
The typed routes feature is built into Vue Router 4.4 and later, but the manual configuration is tedious and error-prone. The official recommendation is to use the file-based routing plugin unplugin-vue-router, which generates the route definitions and the TypeScript types from the filesystem structure . The data loaders feature is experimental but available through vue-router/experimental, and it provides a defineBasicLoader function that declares a loader alongside the page component. The loader is automatically collected and awaited within a navigation guard, so the component receives the data as if it were already available .
Key point: Typed routes are enabled by augmenting the RouteNamedMap interface (or by using unplugin-vue-router to generate it automatically), and data loaders are declared with defineBasicLoader on the page component and awaited during navigation, so the component renders with data instead of an empty state.
Why typed routes and router-level data fetching matter
The stringly-typed routing problem. Vue Router’s router.push() and <RouterLink> accept a route name as a string. If the name is wrong, or if the required params are missing, the navigation fails silently or throws at runtime. The RouteNamedMap interface solves this by mapping each route name to its path, its raw params (what you can pass), and its normalized params (what you get from useRoute()). Once the map is augmented, router.push({ name: 'user', params: { id: 42 } }) is type-checked, and route.params.id is typed as string (or string | number if the raw param accepts numbers) .
The manual configuration problem. The official documentation shows how to define RouteNamedMap by hand, and then explicitly states that this is “tedious and error-prone” and recommends using the file-based routing plugin instead . The plugin (unplugin-vue-router) watches the src/pages directory, converts each .vue file into a route, and generates the RouteNamedMap type automatically. A file at src/pages/users/[id].vue becomes the route /users/:id with the name users-id, and the type is generated without the developer writing any routing configuration .
The data fetching timing problem. The standard Vue pattern is to fetch data in onMounted. This means the navigation completes, the component renders with empty state, and the fetch begins. The user sees a loading state, and the page height may jump when the data arrives. The alternative is to fetch before navigation completes, which is what beforeRouteEnter was designed for . Data loaders formalize this pattern: the loader is defined outside the component’s setup, so it runs before the component is created, and the navigation waits for it to resolve. The component then renders with the data already present .
The reuse and deduplication problem. A loader defined with defineBasicLoader returns a composable that can be used in any component. If two components on the same page use the same loader, they share the same data fetching instance. The loader also handles parallel fetching, loading state, error handling, and SSR support . This is more than a fetch call in onMounted can provide without additional libraries.
a. Typed routes with unplugin-vue-router
The plugin is installed as a dev dependency and registered in the Vite configuration before the Vue plugin. The order matters: the router plugin must run first so that the generated route types are available when Vue processes the components.
// vite.config.ts
import { defineConfig } from 'vite'
import VueRouter from 'unplugin-vue-router/vite'
import Vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
// VueRouter must come before Vue
VueRouter({
// Directory where page components live
routesFolder: 'src/pages',
}),
Vue(),
],
})
After running the dev server once, the plugin generates typed-router.d.ts. This file must be included in tsconfig.json for the types to take effect .
// tsconfig.json
{
"include": ["src/**/*.ts", "src/**/*.vue", "typed-router.d.ts"],
"compilerOptions": {
"moduleResolution": "Bundler"
}
}
The routes are imported from vue-router/auto-routes instead of being defined manually:
// router.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
export const router = createRouter({
history: createWebHistory(),
routes,
})
A file at src/pages/users/[id].vue becomes a route with the path /users/:id. The route name is derived from the file path (users-id), and the id param is typed as a string in useRoute() and as string | number in router.push() . A typo in the route name or a missing param is a compile-time error, not a runtime failure.
b. Data loaders: fetching before navigation completes
Data loaders require the DataLoaderPlugin to be registered before the router. The plugin collects the loaders from the matched page components and awaits them during navigation .
// main.ts
import { createApp } from 'vue'
import { DataLoaderPlugin } from 'vue-router/experimental'
import { router } from './router'
import App from './App.vue'
const app = createApp(App)
// Register the plugin before the router
app.use(DataLoaderPlugin, { router })
app.use(router)
app.mount('#app')
A loader is defined with defineBasicLoader and exported from the page component. The first argument is the route path (used for type inference when typed routes are enabled), and the second is the async function that fetches the data.
<!-- src/pages/users/[id].vue -->
<script lang="ts">
import { defineBasicLoader } from 'vue-router/experimental'
import { getUserById } from '../api'
// Define the loader: fetches the user for the route's :id param
export const useUserData = defineBasicLoader('/users/[id]', async (route) => {
// route.params.id is typed as a string
return getUserById(route.params.id)
})
</script>
<script setup lang="ts">
// Use the composable returned by the loader
const { data: user, isLoading, error, reload } = useUserData()
</script>
<template>
<!-- Data is already available when the component renders -->
<div v-if="user">
<h1>{{ user.name }}</h1>
</div>
<!-- Loading state is only shown on client-side reloads, not initial navigation -->
<div v-else-if="isLoading">Loading...</div>
<div v-else-if="error">{{ error.message }}</div>
</template>
The loader runs before the navigation completes. When navigating from /users/1 to /users/2, the loader for the new route runs, and the navigation is delayed until the data is ready. The component is not created until the loader resolves, which means the template renders with user already populated .
The loader can be marked as lazy to avoid blocking navigation. A lazy loader fetches in the background, and the component renders with isLoading set to true. This is useful for non-critical data that should not delay the page transition .
// Lazy loader: does not block navigation
export const useUserData = defineBasicLoader(
'/users/[id]',
async (route) => getUserById(route.params.id),
{ lazy: true }
)
c. Combining typed routes and loaders with the correct plugin order
The integration has a specific ordering requirement. unplugin-vue-router must be registered before the Vue plugin in vite.config.ts so that the generated route types are available when the Vue components are compiled . The DataLoaderPlugin must be registered on the Vue app before the router so that the loader collection is active during the initial navigation .
The loader’s type inference depends on the typed routes. When unplugin-vue-router is installed, the defineBasicLoader function uses the route path to infer the params type. Without the plugin, the params are typed as Record<string, string | string[]>, and the loader function receives an untyped route object. With the plugin, the path '/users/[id]' is matched against the generated route map, and route.params.id is typed as a string .
The loader can also be used outside the page component. The composable returned by defineBasicLoader can be imported in any child component, and it shares the same data instance. This means a nested component can access the user data without prop drilling, and the loader is still awaited during navigation because it is exported from the page component .
Complete Example Session
The following session builds a typed route with a data loader that fetches a user before the page renders.
// ============================================
// PART 1: THE VITE CONFIGURATION
// ============================================
// vite.config.ts
import { defineConfig } from 'vite'
import VueRouter from 'unplugin-vue-router/vite'
import Vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
// Router plugin must come before Vue
VueRouter({ routesFolder: 'src/pages' }),
Vue(),
],
})
// ============================================
// PART 2: THE ROUTER (AUTO-GENERATED ROUTES)
// ============================================
// router.ts
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
export const router = createRouter({
history: createWebHistory(),
// Routes generated from src/pages/*.vue
routes,
})
// ============================================
// PART 3: THE MAIN ENTRY
// ============================================
// main.ts
import { createApp } from 'vue'
import { DataLoaderPlugin } from 'vue-router/experimental'
import { router } from './router'
import App from './App.vue'
const app = createApp(App)
// Plugin must be registered before the router
app.use(DataLoaderPlugin, { router })
app.use(router)
app.mount('#app')
<!-- ============================================
PART 4: THE PAGE COMPONENT WITH LOADER
============================================ -->
<!-- src/pages/users/[id].vue -->
<script lang="ts">
import { defineBasicLoader } from 'vue-router/experimental'
import { getUserById } from '../../api'
// Loader is awaited during navigation
// route.params.id is typed as string
export const useUserData = defineBasicLoader('/users/[id]', async (route) => {
return getUserById(route.params.id)
})
</script>
<script setup lang="ts">
// The composable provides data, loading, and error states
const { data: user, isLoading, error } = useUserData()
</script>
<template>
<!-- Data is available before the component renders -->
<div v-if="user">
<h1>{{ user.name }}</h1>
</div>
</template>
// ============================================
// PART 5: NAVIGATION WITH TYPED ROUTES
// ============================================
// In any component:
import { useRouter } from 'vue-router'
const router = useRouter()
// Typed: route name and params are checked at compile time
router.push({ name: 'users-id', params: { id: '42' } })
// Typo in route name is a TypeScript error
// router.push({ name: 'user-id' }) // ❌ compile error
The five parts cover the Vite plugin configuration, the auto-generated routes, the DataLoaderPlugin registration, the page component with the loader, and the typed navigation. What the session demonstrates is that the loader runs before the component is created, so the template renders with the data already present, and the route name and params are validated at compile time.
Quick Reference
Typed Routes Setup
| Step | Detail |
|---|---|
| Install | npm i -D unplugin-vue-router |
| Vite plugin | VueRouter() before Vue() |
| Routes folder | src/pages |
| Import routes | vue-router/auto-routes |
| TypeScript | Include typed-router.d.ts in tsconfig |
Data Loader Setup
| Step | Detail |
|---|---|
| Install | Bundled with vue-router/experimental |
| Plugin | app.use(DataLoaderPlugin, { router }) |
| Define | defineBasicLoader(path, asyncFn) |
| Use | const { data, isLoading, error, reload } = useLoader() |
| Lazy | { lazy: true } to not block navigation |
Loader Return Values
| Property | Meaning |
|---|---|
data | The resolved value |
isLoading | Boolean, true while fetching |
error | Error object if the fetch failed |
reload | Function to refetch without navigating |
Route Name Generation
| File | Route Name |
|---|---|
src/pages/index.vue | index |
src/pages/users.vue | users |
src/pages/users/[id].vue | users-id |
src/pages/users/[id]/edit.vue | users-id-edit |
Best Practices
✅ Do This:
// Register the router plugin before Vue
plugins: [VueRouter({ routesFolder: 'src/pages' }), Vue()]
// Export the loader from the page component
export const useUserData = defineBasicLoader('/users/[id]', async (route) => {
return getUserById(route.params.id)
})
// Use lazy loaders for non-critical data
defineBasicLoader(path, fn, { lazy: true })
❌ Don’t Do This:
// Define routes manually and skip typed routes
const routes = [{ path: '/users/:id', component: User }] // no type safety
// Fetch data in onMounted
onMounted(async () => { user.value = await fetchUser() }) // page renders empty
// Forget to include typed-router.d.ts in tsconfig
// Types will not be available
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Route types not available | typed-router.d.ts not included | Add to tsconfig.json include |
| Loader not awaited | Defined in a non-page component | Export from page component |
| Plugin order wrong | Vue() before VueRouter() | Swap the order |
| Loader runs twice | Used in multiple components without sharing | Use the returned composable |
| Navigation blocks too long | Loader is slow | Use lazy loader or show loading state |
Real-World Examples
1. File-based route
src/pages/users/[id].vue → /users/:id (name: users-id)
2. Typed navigation
router.push({ name: 'users-id', params: { id: '42' } })
3. Basic loader
export const useUserData = defineBasicLoader('/users/[id]', async (route) => {
return getUserById(route.params.id)
})
4. Lazy loader
export const usePosts = defineBasicLoader('/posts', fetchPosts, { lazy: true })
5. Use loader in component
const { data, isLoading, error } = useUserData()
6. Reload without navigating
const { reload } = useUserData()
await reload()
7. DataLoaderPlugin registration
app.use(DataLoaderPlugin, { router })
8. Auto-routes import
import { routes } from 'vue-router/auto-routes'
9. Loader in child component
// Child can import the composable from the page
import { useUserData } from '../pages/users/[id].vue'
10. TypeScript config
{ "include": ["typed-router.d.ts"] }
Visual
Typed Routes Flow
┌──────────────────────────────────────────────┐
│ src/pages/users/[id].vue │
│ │ │
│ ▼ │
│ unplugin-vue-router (build time) │
│ │ │
│ ▼ │
│ typed-router.d.ts (generated) │
│ │ │
│ ▼ │
│ RouteNamedMap augmented │
│ │ │
│ ▼ │
│ router.push({ name: 'users-id', params }) │
│ → type-checked at compile time │
└──────────────────────────────────────────────┘
Data Loader Flow
┌──────────────────────────────────────────────┐
│ Navigation to /users/42 │
│ │ │
│ ▼ │
│ DataLoaderPlugin collects loaders │
│ │ │
│ ▼ │
│ Loader runs: getUserById('42') │
│ │ │
│ ▼ │
│ Navigation delayed until loader resolves │
│ │ │
│ ▼ │
│ Component created with data already present │
│ │ │
│ ▼ │
│ Template renders immediately │
└──────────────────────────────────────────────┘
Plugin Order
┌──────────────────────────────────────────────┐
│ CORRECT ORDER │
│ 1. VueRouter() (generates types) │
│ 2. Vue() (compiles components) │
│ │
│ WRONG ORDER │
│ 1. Vue() (no types available) │
│ 2. VueRouter() (too late) │
└──────────────────────────────────────────────┘
Loader Sharing
┌──────────────────────────────────────────────┐
│ Page component exports loader │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ ▼ ▼ │
│ Page uses Child component imports │
│ composable the same composable │
│ │ │ │
│ └────┬────┘ │
│ ▼ │
│ Same data instance, no duplicate fetch │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Typed routes plugin | unplugin-vue-router |
| Routes import | vue-router/auto-routes |
| Generated types file | typed-router.d.ts |
| Plugin order | VueRouter() before Vue() |
| Data loader function | defineBasicLoader |
| Loader plugin | DataLoaderPlugin |
| Loader return | { data, isLoading, error, reload } |
| Lazy loader | { lazy: true } |
| Loader awaited | During navigation, before component creation |
Key takeaways:
- Typed routes eliminate the stringly-typed navigation problem. The
RouteNamedMapinterface maps each route name to its path and params, andunplugin-vue-routergenerates it from the filesystem so you do not write it manually . - The router plugin must be registered before the Vue plugin. The generated types must exist before the Vue components are compiled, or the type inference in
useRoute()anddefineBasicLoaderwill not work . - Data loaders move fetching outside the component lifecycle. The loader runs before the component is created, and the navigation waits for it to resolve. The component renders with data instead of an empty state .
- The
DataLoaderPluginmust be registered before the router. The plugin collects the loaders from the matched page components and awaits them during the initial navigation . - Loaders can be shared across components. The composable returned by
defineBasicLoadercan be imported in any child component, and it uses the same data instance without a duplicate fetch . - Lazy loaders do not block navigation. For non-critical data, the lazy option lets the page render immediately while the data loads in the background .
- Route names are generated from file paths.
src/pages/users/[id].vuebecomes the nameusers-id, and the params are typed according to the path pattern .
Remember: Typed routes and data loaders are two sides of the same router-level improvement. Typed routes make navigation safe at compile time—the route name and params are validated before the code runs. Data loaders make navigation complete with data—the fetch happens before the component is created, so the page renders with content instead of a loading state. The setup is a plugin configuration in Vite, a plugin registration in the app, and a loader definition on the page component. The result is a router configuration that catches its own mistakes and a page that does not flash empty before the data arrives.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!