vite-mastery

13.2 · difficulty 4/4 · 20 min read

Source Walkthrough: `vite-plugin-pages`

Learn file-system routing as a complete Vite plugin: directory scanning, route parsing, virtual modules, type declarations, HMR invalidation, and production-grade edge cases.

Vite 8.1Stable

Start With The Target

The core job of vite-plugin-pages is not "generate some strings". It turns the filesystem into a route module:

text
src/pages/
  index.vue              -> /
  about.vue              -> /about
  blog/index.vue         -> /blog
  blog/[id].vue          -> /blog/:id
  [...all].vue           -> /*

The application imports one module:

ts
import routes from "~pages"

That import looks like a real file, but it is usually virtual. When Vite resolves ~pages, the plugin answers through resolveId / load and returns generated code containing the route array.

Keep five threads in mind while reading the source:

  1. Option normalization: page directory, extensions, exclude rules, import mode, framework adapters.
  2. Filesystem scanning: find candidate pages, normalize Windows/POSIX paths, sort consistently.
  3. Route parsing: map file paths to router-friendly path, name, children, and meta.
  4. Virtual module output: expose ~pages or a similar ID with generated, debuggable code.
  5. HMR invalidation: when page files change, invalidate and reload the route module.

Why Not Write A Real File?

Writing src/generated/routes.ts can work, but it creates avoidable friction:

  • Every scan pollutes the source tree, and users may commit generated files by accident.
  • Editors, formatters, and linters treat generated output as hand-written code.
  • The dev server can fall into a loop: plugin writes file, watcher reacts, plugin writes again.
  • SSR, tests, monorepos, and multiple roots make the generated file location a configuration concern.

Virtual modules keep this inside the plugin. Users import a stable ID. The plugin generates module code in memory. During production build, the same resolveId / load path is called by Rolldown, so dev and build stay aligned.

A Teaching Implementation

This is not a line-by-line copy of the real package. It preserves the important shape of a file-routing plugin while keeping the code readable.

ts
import path from "node:path"
import { globSync } from "tinyglobby"
import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"
import { normalizePath } from "vite"

const VIRTUAL_ID = "~pages"
const RESOLVED_ID = "\0vite-plugin-pages:routes"

interface PageRoute {
  path: string
  file: string
}

interface PagesOptions {
  dir?: string
  extensions?: string[]
}

export function pagesPlugin(options: PagesOptions = {}): Plugin {
  let config: ResolvedConfig
  let server: ViteDevServer | undefined
  let pagesDir = ""
  const extensions = options.extensions ?? ["vue", "tsx", "jsx"]

  function resolvePagesDir() {
    pagesDir = path.resolve(config.root, options.dir ?? "src/pages")
  }

  function scanPages(): PageRoute[] {
    const pattern = `**/*.{${extensions.join(",")}}`
    const files = globSync(pattern, {
      cwd: pagesDir,
      absolute: true,
      onlyFiles: true,
    })

    return files
      .map((file) => normalizePath(file))
      .sort()
      .map((file) => ({
        file,
        path: fileToRoutePath(path.posix.relative(normalizePath(pagesDir), file)),
      }))
  }

  function generateRoutesModule() {
    const routes = scanPages()

    const imports = routes
      .map((route, index) => {
        const importPath = "/" + path.posix.relative(config.root, route.file)
        return `const page${index} = () => import(${JSON.stringify(importPath)})`
      })
      .join("\n")

    const records = routes
      .map((route, index) => {
        return `{ path: ${JSON.stringify(route.path)}, component: page${index} }`
      })
      .join(",\n  ")

    return `${imports}

export default [
  ${records}
]
`
  }

  return {
    name: "vite-plugin-pages:teaching",

    configResolved(resolved) {
      config = resolved
      resolvePagesDir()
    },

    configureServer(_server) {
      server = _server
      server.watcher.add(pagesDir)
    },

    resolveId(id) {
      if (id === VIRTUAL_ID) return RESOLVED_ID
      return null
    },

    load(id) {
      if (id !== RESOLVED_ID) return null
      return generateRoutesModule()
    },

    handleHotUpdate(ctx) {
      const file = normalizePath(ctx.file)
      if (!file.startsWith(normalizePath(pagesDir) + "/")) return

      const mod = ctx.server.moduleGraph.getModuleById(RESOLVED_ID)
      if (!mod) return

      ctx.server.moduleGraph.invalidateModule(mod)
      ctx.server.ws.send({ type: "full-reload" })
      return []
    },
  }
}

Three details are easy to miss:

  • Use normalizePath, or Windows backslashes will break route parsing and startsWith checks.
  • Generated import paths must be Vite-resolvable module paths. Do not emit OS absolute paths into browser code.
  • Watching the directory matters more than watching existing files, because a newly created page was not previously imported by anything.

Route Path Parsing

Path parsing is where simple demos become production bugs. A small parser can look like this:

ts
function fileToRoutePath(relativeFile: string) {
  const withoutExt = relativeFile.replace(/\.(vue|tsx|jsx)$/, "")
  const segments = withoutExt.split("/")

  const routeSegments = segments.flatMap((segment) => {
    if (segment === "index") return []
    if (/^\[\.\.\.(.+)\]$/.test(segment)) return ["*"]
    if (/^\[(.+)\]$/.test(segment)) return [":" + segment.slice(1, -1)]
    return [segment]
  })

  return "/" + routeSegments.join("/")
}

Real projects need rules for more cases:

CaseRequired handling
blog.vue and blog/index.vue both existThey map to the same route; report an error or define priority
[id].vue next to settings.vueStatic routes should usually sort before dynamic routes
[...all].vueCatch-all routes should come last
_layout.vue, _middleware.tsConvention files may not be pages
(admin)/users.vueDecide whether route groups appear in the URL
users.[id].vueDot segments and nested conventions must be specified

If the plugin only serves one internal team, narrow rules are fine. If it is published to npm, those rules need to be in the README, type declarations, and tests.

Generated Modules Should Be Readable

Generated code appears in sourcemaps, stack traces, build output, and user debugging sessions. Keep it readable.

ts
function generateReadableRouteModule(routes: PageRoute[]) {
  const lines: string[] = []

  routes.forEach((route, index) => {
    lines.push(`const route${index} = () => import(${JSON.stringify(route.file)})`)
  })

  lines.push("")
  lines.push("export const routes = [")

  routes.forEach((route, index) => {
    lines.push(`  {`)
    lines.push(`    path: ${JSON.stringify(route.path)},`)
    lines.push(`    component: route${index},`)
    lines.push(`  },`)
  })

  lines.push("]")
  lines.push("export default routes")

  return lines.join("\n")
}

Avoid clever template gymnastics in generated code. A real plugin is easier to debug when users can inspect the virtual module and understand what happened.

Type Declarations Are Part Of The Plugin

If users write:

ts
import routes from "~pages"

TypeScript needs a declaration:

ts
declare module "~pages" {
  const routes: unknown[]
  export default routes
}

Then users reference it from their Vite env file:

ts
/// <reference types="vite-plugin-pages/client" />

Tutorials often skip this, but developer experience is incomplete without it. A plugin that runs but breaks editor feedback is still unfinished.

Why HMR Often Falls Back To Full Reload

Editing a page component can use normal module HMR. Changing route structure is different:

  • Add src/pages/admin.vue: the route array gains an entry.
  • Delete src/pages/blog/[id].vue: the route array loses an entry.
  • Rename about.vue to about-us.vue: the path changes.
  • Edit route block / meta: extra route data changes.

The affected module is ~pages, not only the page component. The safest teaching implementation invalidates the virtual module and triggers a full reload. More advanced implementations can propagate partial HMR to importers of ~pages, but the application router must be able to replace its route table safely.

ts
function invalidateRoutesModule(server: ViteDevServer) {
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
  if (!mod) return

  server.moduleGraph.invalidateModule(mod)
  server.ws.send({ type: "full-reload" })
}

For a production plugin, also test add, unlink, change, directory renames, case-only renames, and monorepo symlinks.

What Real Plugins Add

The real package does more than the small skeleton:

  1. Framework-specific output: Vue Router, React Router, and Solid Router records differ.
  2. Lazy vs eager imports: lazy imports are great for dev and code splitting; some cases need eager imports.
  3. Route blocks and meta: Vue SFC custom blocks or named exports can feed route metadata.
  4. extendRoute / onRoutesGenerated hooks: users need a last chance to mutate the route tree.
  5. Stable ordering: static, dynamic, and catch-all routes must sort deterministically.
  6. Caching and performance: large apps should not rescan every file on every virtual module request.
  7. Type exports: module IDs, route records, and plugin options need declarations.
  8. Debug output: users need to trace which file produced which route.

That is why file-routing plugins are excellent Vite plugin study material. They touch configResolved, configureServer, resolveId, load, and handleHotUpdate, while exposing a very small user-facing API.

When reading the real source, do not start from the string generation function. Use this order:

  1. README and option types: learn what the plugin promises.
  2. Entry function: inspect defaults, returned plugin objects, and plugin names.
  3. Context/state object: find methods such as scanPages, resolveOptions, and generateRoutes.
  4. Resolver/parser: learn how filenames become route paths.
  5. Virtual module: find resolveId, load, and module ID constants.
  6. Watcher/HMR: find configureServer, handleHotUpdate, and server.moduleGraph.invalidateModule.
  7. Tests: tests usually reveal edge-case semantics more clearly than implementation code.

If the source uses caching, ignore the cache at first. Draw the flow from input files to route records to virtual module code, then revisit which step the cache optimizes.

Common Failure Modes

Self-check

  1. Why is ~pages better as a virtual module than as src/generated/routes.ts?
  2. Why is this.addWatchFile(file) inside load() not enough when users create new page files?
  3. If pages/blog.vue and pages/blog/index.vue both exist, would you throw or define priority? Why?
  4. If you support route meta, would you parse source at build time or import and read exports at runtime? What are the tradeoffs?
ts
// Extend the teaching pagesPlugin:
// 1. Support route meta from `export const meta = {}`
// 2. Detect duplicate route paths and throw an error with file names
// 3. Sort static, dynamic, and catch-all routes deterministically
// 4. Add a client.d.ts declaration for `~pages`