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.
Start With The Target
The core job of vite-plugin-pages is not "generate some strings". It turns the filesystem into a route module:
src/pages/
index.vue -> /
about.vue -> /about
blog/index.vue -> /blog
blog/[id].vue -> /blog/:id
[...all].vue -> /*The application imports one module:
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:
- Option normalization: page directory, extensions, exclude rules, import mode, framework adapters.
- Filesystem scanning: find candidate pages, normalize Windows/POSIX paths, sort consistently.
- Route parsing: map file paths to router-friendly
path,name,children, andmeta. - Virtual module output: expose
~pagesor a similar ID with generated, debuggable code. - 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.
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 andstartsWithchecks. - 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:
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:
| Case | Required handling |
|---|---|
blog.vue and blog/index.vue both exist | They map to the same route; report an error or define priority |
[id].vue next to settings.vue | Static routes should usually sort before dynamic routes |
[...all].vue | Catch-all routes should come last |
_layout.vue, _middleware.ts | Convention files may not be pages |
(admin)/users.vue | Decide whether route groups appear in the URL |
users.[id].vue | Dot 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.
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:
import routes from "~pages"TypeScript needs a declaration:
declare module "~pages" {
const routes: unknown[]
export default routes
}Then users reference it from their Vite env file:
/// <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.vuetoabout-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.
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:
- Framework-specific output: Vue Router, React Router, and Solid Router records differ.
- Lazy vs eager imports: lazy imports are great for dev and code splitting; some cases need eager imports.
- Route blocks and meta: Vue SFC custom blocks or named exports can feed route metadata.
extendRoute/onRoutesGeneratedhooks: users need a last chance to mutate the route tree.- Stable ordering: static, dynamic, and catch-all routes must sort deterministically.
- Caching and performance: large apps should not rescan every file on every virtual module request.
- Type exports: module IDs, route records, and plugin options need declarations.
- 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.
Recommended Reading Order
When reading the real source, do not start from the string generation function. Use this order:
- README and option types: learn what the plugin promises.
- Entry function: inspect defaults, returned plugin objects, and plugin names.
- Context/state object: find methods such as
scanPages,resolveOptions, andgenerateRoutes. - Resolver/parser: learn how filenames become route paths.
- Virtual module: find
resolveId,load, and module ID constants. - Watcher/HMR: find
configureServer,handleHotUpdate, andserver.moduleGraph.invalidateModule. - 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
- Why is
~pagesbetter as a virtual module than assrc/generated/routes.ts? - Why is
this.addWatchFile(file)insideload()not enough when users create new page files? - If
pages/blog.vueandpages/blog/index.vueboth exist, would you throw or define priority? Why? - If you support route meta, would you parse source at build time or import and read exports at runtime? What are the tradeoffs?
// 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`