| |

Vue.js 60 🟢 Typed Routing and Automated Data Fetching at Router Level

⏱️ Estimated read: 13 min | |

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

StepDetail
Installnpm i -D unplugin-vue-router
Vite pluginVueRouter() before Vue()
Routes foldersrc/pages
Import routesvue-router/auto-routes
TypeScriptInclude typed-router.d.ts in tsconfig

Data Loader Setup

StepDetail
InstallBundled with vue-router/experimental
Pluginapp.use(DataLoaderPlugin, { router })
DefinedefineBasicLoader(path, asyncFn)
Useconst { data, isLoading, error, reload } = useLoader()
Lazy{ lazy: true } to not block navigation

Loader Return Values

PropertyMeaning
dataThe resolved value
isLoadingBoolean, true while fetching
errorError object if the fetch failed
reloadFunction to refetch without navigating

Route Name Generation

FileRoute Name
src/pages/index.vueindex
src/pages/users.vueusers
src/pages/users/[id].vueusers-id
src/pages/users/[id]/edit.vueusers-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

PitfallWhy It HappensFix
Route types not availabletyped-router.d.ts not includedAdd to tsconfig.json include
Loader not awaitedDefined in a non-page componentExport from page component
Plugin order wrongVue() before VueRouter()Swap the order
Loader runs twiceUsed in multiple components without sharingUse the returned composable
Navigation blocks too longLoader is slowUse 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

ItemValue
Typed routes pluginunplugin-vue-router
Routes importvue-router/auto-routes
Generated types filetyped-router.d.ts
Plugin orderVueRouter() before Vue()
Data loader functiondefineBasicLoader
Loader pluginDataLoaderPlugin
Loader return{ data, isLoading, error, reload }
Lazy loader{ lazy: true }
Loader awaitedDuring navigation, before component creation

Key takeaways:

  • Typed routes eliminate the stringly-typed navigation problem. The RouteNamedMap interface maps each route name to its path and params, and unplugin-vue-router generates 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() and defineBasicLoader will 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 DataLoaderPlugin must 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 defineBasicLoader can 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].vue becomes the name users-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.


Was this helpful?


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!