@troyio/vite-file-router 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,484 +1,664 @@
1
- # vite-file-router
2
-
3
- A Vite plugin that generates a route tree from a file-based page directory.
4
-
5
- ## Install
6
-
7
- ```sh
8
- npm install @troyio/vite-file-router
9
- ```
10
-
11
- Vite is a peer dependency and must also be installed in your project.
12
-
13
- ## Configure
14
-
15
- ```ts
16
- import { defineConfig } from "vite";
17
- import { routes } from "@troyio/vite-file-router";
18
-
19
- export default defineConfig({
20
- plugins: [routes()],
21
- });
22
- ```
23
-
24
- By default, the plugin scans `src/pages` and recognizes the file conventions
25
- listed below. Pass a path relative to the Vite project root to use another
26
- directory:
27
-
28
- ```ts
29
- plugins: [routes("src/routes")];
30
- ```
31
-
32
- You can also configure the directory and special filenames together.
33
- Convention values are filename stems without extensions. Page and component
34
- conventions use `.jsx` / `.tsx` files (`.tsx` takes precedence); `loader` and
35
- `action` conventions use `.js` / `.ts` files (`.ts` takes precedence):
36
-
37
- ```ts
38
- plugins: [
39
- routes({
40
- directory: "app/views",
41
- conventions: {
42
- page: "route",
43
- layout: "shell",
44
- loading: "pending",
45
- notFound: "missing",
46
- error: "fault",
47
- loader: "load",
48
- action: "mutate",
49
- },
50
- }),
51
- ];
52
- ```
53
-
54
- Omitted convention values keep their defaults: `page`, `layout`, `loading`,
55
- `not-found`, `error`, `loader`, and `action`. Each convention must have a
56
- unique filename stem.
57
-
58
- The router scans the pages directory recursively. Each directory becomes a
59
- nested route, and the following files are recognized:
60
-
61
- | File | Purpose |
62
- | --- | --- |
63
- | `page.jsx` / `page.tsx` | The page component for the directory's URL. |
64
- | `layout.jsx` / `layout.tsx` | A layout around the directory's page and child routes. |
65
- | `loading.jsx` / `loading.tsx` | A Suspense fallback for the directory's page and child routes. |
66
- | `not-found.jsx` / `not-found.tsx` | A not-found page for unmatched URLs within the directory's route subtree. |
67
- | `error.jsx` / `error.tsx` | A React Router error boundary for the directory's route subtree. |
68
- | `loader.js` / `loader.ts` | A React Router loader for the directory's route and its descendants. |
69
- | `action.js` / `action.ts` | A React Router action selected when that directory route is the submission target. |
70
-
71
- Component files must default-export a React component. Loader and action
72
- modules must named-export their handler as `loader` or `action`; if both
73
- supported extensions exist for the same convention in one directory, the
74
- TypeScript file takes precedence.
75
-
76
- For example, this structure creates a home page, user pages, and two auth
77
- pages:
78
-
79
- ```text
80
- src/pages/
81
- page.jsx
82
- layout.tsx
83
- loading.tsx
84
- loader.ts
85
- users/
86
- page.tsx
87
- action.ts
88
- [id]/
89
- page.tsx
90
- docs/
91
- [[lang]]/
92
- page.tsx
93
- (auth)/
94
- layout.tsx
95
- sign-in/
96
- page.tsx
97
- sign-up/
98
- page.tsx
99
- [...slug]/
100
- page.tsx
101
- ```
102
-
103
- This maps to `/`, `/users`, `/users/:id`, `/docs/:lang?`, `/sign-in`,
104
- `/sign-up`, and `/*`. Square-bracket segments are dynamic parameters: `[id]`
105
- becomes `:id`; double-bracket segments are optional dynamic parameters, so
106
- `[[lang]]` becomes `:lang?` and matches both `/docs` and `/docs/en`.
107
- Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
108
- are route groups; they organize files and can have their own page, layout,
109
- loading files, loaders, and actions, but their names do not add a URL segment.
110
-
111
- Directories whose names start with an underscore, such as `_components` or
112
- `_utils`, are private folders. The plugin skips them and everything nested
113
- inside them, so you can colocate components, hooks, and helpers next to the
114
- routes that use them without creating routes. A `_components/page.tsx` file is
115
- never routed.
116
-
117
- ### Layouts
118
-
119
- The plugin adapts React Router's matched child route to a `children` prop. A
120
- layout can render that prop directly and does not need to import or render
121
- `Outlet`:
122
-
123
- ```tsx
124
- import type { PropsWithChildren } from "react";
125
-
126
- export default function Layout({ children }: PropsWithChildren) {
127
- return <div className="app-shell">{children}</div>;
128
- }
129
- ```
130
-
131
- ### Loading fallbacks
132
-
133
- Add a `loading.jsx` or `loading.tsx` beside a page or layout to show a Suspense
134
- fallback while that route subtree loads:
135
-
136
- ```tsx
137
- export default function Loading() {
138
- return <p>Loading...</p>;
139
- }
140
- ```
141
-
142
- A nested loading file creates a more specific boundary for that directory.
143
-
144
- ### Not-found pages
145
-
146
- Add a `not-found.jsx` or `not-found.tsx` file to handle unmatched URLs. The plugin generates
147
- a catch-all child route, and the most specific matching route subtree's
148
- not-found page is used:
149
-
150
- ```text
151
- src/pages/
152
- not-found.tsx
153
- users/
154
- not-found.tsx
155
- page.tsx
156
- ```
157
-
158
- The root not-found page handles unknown URLs generally, while `users/not-found.tsx`
159
- handles unmatched URLs under `/users`.
160
-
161
- ### Route error boundaries
162
-
163
- Add an `error.jsx` or `error.tsx` file to handle errors from that route and its
164
- descendants. The nearest route boundary is used, following React Router's error
165
- handling behavior. Use React Router's `useRouteError` hook to read the error:
166
-
167
- ```tsx
168
- import { useRouteError } from "react-router";
169
-
170
- export default function ErrorPage() {
171
- const error = useRouteError();
172
- return <main>Something went wrong: {String(error)}</main>;
173
- }
174
- ```
175
-
176
- ### Loaders, actions, and route guards
177
-
178
- Add a `loader.ts` / `loader.js` or `action.ts` / `action.js` beside the route
179
- files and export a named handler. The generated React Router route calls these
180
- handlers with React Router's loader/action arguments. Their returned values,
181
- thrown errors, and redirect responses use React Router's normal data-router
182
- behavior. A loader on a directory route runs when that route matches, including
183
- when a descendant URL matches. An action runs only when its route is selected
184
- as the submission target; a parent action does not automatically handle
185
- submissions targeting child routes.
186
-
187
- A loader module may also export `shouldRevalidate(args)` to control the route's
188
- React Router `shouldRevalidate` option. It receives the same arguments as
189
- `shouldRevalidate` and must synchronously return a boolean:
190
-
191
- ```ts
192
- import type { LoaderFunctionArgs, ShouldRevalidateFunctionArgs } from "react-router";
193
-
194
- export function shouldRevalidate({ nextUrl, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
195
- if (nextUrl.searchParams.get("refresh") === "never") return false;
196
- return defaultShouldRevalidate;
197
- }
198
-
199
- export async function loader({ request }: LoaderFunctionArgs) {
200
- return getDashboardData(request);
201
- }
202
- ```
203
-
204
- Use a loader to redirect users who should not enter a protected route subtree.
205
- For example, `src/pages/dashboard/loader.ts` can guard `/dashboard` and all of
206
- its nested pages:
207
-
208
- ```ts
209
- import { redirect, type LoaderFunctionArgs } from "react-router";
210
- import { getUserForRequest } from "../../lib/auth.server";
211
-
212
- export async function loader({ request }: LoaderFunctionArgs) {
213
- const user = await getUserForRequest(request);
214
- if (!user) {
215
- const returnTo = new URL(request.url).pathname;
216
- throw redirect(`/sign-in?returnTo=${encodeURIComponent(returnTo)}`);
217
- }
218
- return null;
219
- }
220
- ```
221
-
222
- Actions use the same pattern and receive React Router's `ActionFunctionArgs`:
223
-
224
- ```ts
225
- import { redirect, type ActionFunctionArgs } from "react-router";
226
-
227
- export async function action({ request }: ActionFunctionArgs) {
228
- const formData = await request.formData();
229
- await updateProfile(formData);
230
- return redirect("/profile");
231
- }
232
- ```
233
-
234
- When a directory has both a page and a loader, that page is generated as an
235
- index child route, while the loader remains on the directory route so it also
236
- runs for descendant pages. Read the directory loader's result in the page with
237
- React Router's `useRouteLoaderData`. The generated route ID is
238
- `file-route:` followed by the directory path relative to the Vite project
239
- root, with `/` separators:
240
-
241
- ```tsx
242
- import { useRouteLoaderData } from "react-router";
243
-
244
- export default function DashboardPage() {
245
- const data = useRouteLoaderData<{ message: string }>(
246
- "file-route:src/pages/dashboard",
247
- );
248
- return <h1>{data?.message}</h1>;
249
- }
250
- ```
251
-
252
- To get the type from the loader module instead of writing it by hand, use
253
- `useRouteData` from `virtual:route-helpers`. See
254
- [Typed params and loader data](#typed-params-and-loader-data).
255
-
256
- The authentication and mutation functions above are application code. A
257
- client-side route guard controls navigation and presentation; it is not a
258
- security boundary. Protect data and mutations in the server/API layer as well,
259
- and do not rely on a parent loader running before descendant loaders.
260
-
261
- ### Dynamic routes and route groups
262
-
263
- Two pages must not resolve to the same URL. This includes pages with the same
264
- path in separate route groups, such as `(auth)/sign-in/page.tsx` and
265
- `(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
266
- page listing the conflicting files; production builds fail with the same
267
- diagnostic until the conflict is fixed. Dynamic parameter names do not make
268
- otherwise identical patterns distinct (`users/[id]` and `users/[slug]`
269
- conflict). Static and dynamic siblings such as `users/new` and `users/[id]`
270
- are distinct; React Router's normal route ranking selects the static route
271
- first.
272
-
273
- ## Use the generated routes
274
-
275
- Import `routes` from the virtual module and pass it to React Router:
276
-
277
- ```tsx
278
- import { createBrowserRouter } from "react-router";
279
- import { RouterProvider } from "react-router";
280
- import { routes } from "virtual:routes";
281
-
282
- const router = createBrowserRouter(routes);
283
-
284
- export default function App() {
285
- return <RouterProvider router={router} />;
286
- }
287
- ```
288
-
289
- ### Route manifest
290
-
291
- Import `routeManifest` from `virtual:route-manifest` for a stable, nested
292
- description of the scanned routes. Source files are relative to the Vite
293
- project root and use forward slashes. Each node includes its URL `fullPath`,
294
- the parameters introduced by that segment, any `optionalParams`, and its
295
- recognized files:
296
-
297
- ```ts
298
- import { routeManifest } from "virtual:route-manifest";
299
-
300
- console.log(routeManifest.children);
301
- ```
302
-
303
- The root node has `fullPath: "/"`; route-group nodes have no URL segment but
304
- remain in the tree. React Router's generated `routes` remain the runtime source
305
- of truth.
306
-
307
- The development server watches the configured directory. Adding, removing,
308
- renaming, or editing route files invalidates all generated virtual modules and
309
- reloads the app without requiring a server restart.
310
-
311
- ### Route tree inspection
312
-
313
- The route tree can be inspected in Vite DevTools. This requires Vite 8.3 or
314
- later and the DevTools package:
315
-
316
- ```sh
317
- npm install -D @vitejs/devtools
318
- ```
319
-
320
- Enable the dock in your Vite configuration:
321
-
322
- ```ts
323
- export default defineConfig({
324
- devtools: true,
325
- plugins: [routes()],
326
- });
327
- ```
328
-
329
- Open the **Routes** dock to browse the nested route tree, filter by path or
330
- source file, and inspect parameters and special files. The panel refreshes its
331
- manifest automatically while the dev server is running. Vite DevTools must be
332
- enabled; route-tree inspection is development-only.
333
-
334
- ### Typed route helpers
335
-
336
- Import `routePath` from `virtual:route-helpers` to build paths for discovered
337
- pages. The helper is compatible with React Router's `Link`, `NavLink`,
338
- `navigate`, and `redirect` APIs. Dynamic parameter names and required values are
339
- checked from the path literal; values are URL-encoded. Splat values use the
340
- React Router `"*"` parameter and are encoded segment-by-segment so `/`
341
- separators are preserved:
342
-
343
- ```tsx
344
- import { Link } from "react-router";
345
- import { routePath } from "virtual:route-helpers";
346
-
347
- export function UserLink({ userId }: { userId: string }) {
348
- return (
349
- <Link to={routePath("/users/:userId", { userId })}>
350
- View user
351
- </Link>
352
- );
353
- }
354
-
355
- const docsPath = routePath("/docs/*", { "*": "guides/getting-started" });
356
- const localizedDocsPath = routePath("/docs/:lang?", { lang: "en" });
357
- const defaultDocsPath = routePath("/docs/:lang?");
358
- ```
359
-
360
- Static routes and routes with only optional parameters need no parameter object.
361
- An optional parameter can also be omitted from the params object; its URL
362
- segment is removed. Unknown route patterns and missing required or extra
363
- parameters are rejected at runtime. Routes whose optional segment overlaps
364
- another page's URL pattern are reported as conflicts. The generated
365
- `routePatterns` export contains the discovered page paths.
366
-
367
- ### Generated route types
368
-
369
- When the dev server starts and at the start of each build, the plugin writes
370
- `src/route-types.d.ts`. It rewrites the file whenever route files are added,
371
- removed, or renamed, and leaves it alone when nothing changed. The file lists
372
- every discovered page pattern and loader module. With it in your TypeScript
373
- project, `routePath`, `routePatterns`, and the hooks below only accept routes
374
- that exist, and your editor autocompletes them:
375
-
376
- ```ts
377
- routePath("/users/:userId", { userId }); // OK
378
- routePath("/usres/:userId", { userId }); // Type error: unknown route pattern
379
- ```
380
-
381
- Change the output path, or turn the file off, with the `dts` option. The path
382
- is relative to the Vite project root and must end in `.d.ts`:
383
-
384
- ```ts
385
- plugins: [routes({ dts: "types/route-types.d.ts" })];
386
- plugins: [routes({ dts: false })];
387
- ```
388
-
389
- Without the file, route patterns are typed as `string` and loader data as
390
- `unknown`, the same as before. You can commit the file so types work before
391
- the first dev run, or add it to `.gitignore`.
392
-
393
- ### Typed params and loader data
394
-
395
- `virtual:route-helpers` also exports two hooks that wrap React Router's hooks
396
- with types taken from the route tree:
397
-
398
- - `useRouteParams(pattern)` returns `useParams()`, typed for that pattern.
399
- Required params are `string`, optional params are `string | undefined`, and
400
- a splat is a `string` under the `"*"` key.
401
- - `useRouteData(key)` returns `useRouteLoaderData()`, typed from the loader
402
- module. The key is either a page pattern, which reads the loader in that
403
- page's directory, or a loader route ID such as
404
- `"file-route:src/pages/users/[userId]"`, which can read any matched loader,
405
- including a parent's. It returns `undefined` when that route isn't matched,
406
- just like React Router's hook.
407
-
408
- ```tsx
409
- // src/pages/users/[userId]/loader.ts
410
- import type { LoaderFunctionArgs } from "react-router";
411
-
412
- export async function loader({ params }: LoaderFunctionArgs) {
413
- return { name: await getUserName(params.userId!) };
414
- }
415
-
416
- // src/pages/users/[userId]/page.tsx
417
- import { useRouteData, useRouteParams } from "virtual:route-helpers";
418
-
419
- export default function UserPage() {
420
- const { userId } = useRouteParams("/users/:userId"); // { userId: string }
421
- const data = useRouteData("/users/:userId"); // { name: string } | undefined
422
- return <h1>{data?.name ?? userId}</h1>;
423
- }
424
- ```
425
-
426
- The `RouteParams<"/users/:userId">`, `RouteLoaderData<key>`, `RoutePattern`,
427
- and `RouteLoaderKey` types are exported from `@troyio/vite-file-router` for
428
- your own helpers. Loader data is typed the same way as React Router's
429
- `useRouteLoaderData<typeof loader>()`. JavaScript loader modules are typed as
430
- `any`. Both hooks throw if they're passed a pattern or key the plugin didn't
431
- discover.
432
-
433
- ### Route preloading
434
-
435
- `virtual:routes` exports `preloadRoute(to, basename?)`. It matches a URL
436
- against the route tree and imports the page, layout, and loader modules of
437
- every matched route, so the next navigation there doesn't wait for those
438
- downloads. Error and action modules are left out, since they're only needed
439
- after an error or a form submission. Importing modules doesn't run loaders.
440
- Each module downloads once, however often you call it. The promise resolves to
441
- `false` when no route matches the URL.
442
-
443
- Call it when a user is likely to navigate, such as on link hover or focus:
444
-
445
- ```tsx
446
- import { Link, type LinkProps } from "react-router";
447
- import { preloadRoute } from "virtual:routes";
448
-
449
- export function PreloadLink(props: LinkProps & { to: string }) {
450
- const preload = () => void preloadRoute(props.to);
451
- return <Link {...props} onMouseEnter={preload} onFocus={preload} />;
452
- }
453
- ```
454
-
455
- If the router uses a `basename`, pass it as the second argument. `to` also
456
- accepts a partial location such as `{ pathname: "/users/42" }`.
457
-
458
- ### TypeScript setup
459
-
460
- Add Vite's client types and the package's virtual-module declaration to your
461
- application TypeScript project. For example, create `src/vite-env.d.ts`:
462
-
463
- ```ts
464
- /// <reference types="vite/client" />
465
- /// <reference types="@troyio/vite-file-router" />
466
- ```
467
-
468
- If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
469
- and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
470
- `virtual:routes`. Make sure the generated `src/route-types.d.ts`, or the path
471
- you set with `dts`, is included by your `tsconfig.json`. Restart your editor's
472
- TypeScript server if the declarations aren't picked up immediately.
473
-
474
- ## Build
475
-
476
- ```sh
477
- npm run build
478
- ```
479
-
480
- ## Test
481
-
482
- ```sh
483
- npm test
484
- ```
1
+ # vite-file-router
2
+
3
+ A Vite plugin that generates a route tree from a file-based page directory.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @troyio/vite-file-router
9
+ ```
10
+
11
+ Vite is a peer dependency and must also be installed in your project.
12
+
13
+ ## Configure
14
+
15
+ ```ts
16
+ import { defineConfig } from "vite";
17
+ import { routes } from "@troyio/vite-file-router";
18
+
19
+ export default defineConfig({
20
+ plugins: [routes()],
21
+ });
22
+ ```
23
+
24
+ By default, the plugin scans `src/pages` and recognizes the file conventions
25
+ listed below. Pass a path relative to the Vite project root to use another
26
+ directory:
27
+
28
+ ```ts
29
+ plugins: [routes("src/routes")];
30
+ ```
31
+
32
+ You can also configure the directory and special filenames together.
33
+ Convention values are filename stems without extensions. Page and component
34
+ conventions use `.jsx` / `.tsx` files (`.tsx` takes precedence); `loader`,
35
+ `action`, and `middleware` conventions use `.js` / `.ts` files (`.ts` takes
36
+ precedence):
37
+
38
+ ```ts
39
+ plugins: [
40
+ routes({
41
+ directory: "app/views",
42
+ conventions: {
43
+ page: "route",
44
+ layout: "shell",
45
+ loading: "pending",
46
+ notFound: "missing",
47
+ error: "fault",
48
+ loader: "load",
49
+ action: "mutate",
50
+ middleware: "guard",
51
+ },
52
+ }),
53
+ ];
54
+ ```
55
+
56
+ Omitted convention values keep their defaults: `page`, `layout`, `loading`,
57
+ `not-found`, `error`, `loader`, `action`, and `middleware`. Each convention
58
+ must have a unique filename stem.
59
+
60
+ The router scans the pages directory recursively. Each directory becomes a
61
+ nested route, and the following files are recognized:
62
+
63
+ | File | Purpose |
64
+ | --- | --- |
65
+ | `page.jsx` / `page.tsx` | The page component for the directory's URL. |
66
+ | `layout.jsx` / `layout.tsx` | A layout around the directory's page and child routes. |
67
+ | `loading.jsx` / `loading.tsx` | A Suspense fallback for the directory's page and child routes. |
68
+ | `not-found.jsx` / `not-found.tsx` | A not-found page for unmatched URLs within the directory's route subtree. |
69
+ | `error.jsx` / `error.tsx` | A React Router error boundary for the directory's route subtree. |
70
+ | `loader.js` / `loader.ts` | A React Router loader for the directory's route and its descendants. |
71
+ | `action.js` / `action.ts` | A React Router action selected when that directory route is the submission target. |
72
+ | `middleware.js` / `middleware.ts` | React Router middleware that runs before every loader and action at or below the directory. |
73
+
74
+ Component files must default-export a React component. Loader and action
75
+ modules must named-export their handler as `loader` or `action`; if both
76
+ supported extensions exist for the same convention in one directory, the
77
+ TypeScript file takes precedence.
78
+
79
+ For example, this structure creates a home page, user pages, and two auth
80
+ pages:
81
+
82
+ ```text
83
+ src/pages/
84
+ page.jsx
85
+ layout.tsx
86
+ loading.tsx
87
+ loader.ts
88
+ users/
89
+ page.tsx
90
+ action.ts
91
+ [id]/
92
+ page.tsx
93
+ docs/
94
+ [[lang]]/
95
+ page.tsx
96
+ (auth)/
97
+ layout.tsx
98
+ sign-in/
99
+ page.tsx
100
+ sign-up/
101
+ page.tsx
102
+ [...slug]/
103
+ page.tsx
104
+ ```
105
+
106
+ This maps to `/`, `/users`, `/users/:id`, `/docs/:lang?`, `/sign-in`,
107
+ `/sign-up`, and `/*`. Square-bracket segments are dynamic parameters: `[id]`
108
+ becomes `:id`; double-bracket segments are optional dynamic parameters, so
109
+ `[[lang]]` becomes `:lang?` and matches both `/docs` and `/docs/en`.
110
+ Catch-all segments such as `[...slug]` become `*`. Parenthesized directories
111
+ are route groups; they organize files and can have their own page, layout,
112
+ loading files, loaders, and actions, but their names do not add a URL segment.
113
+
114
+ Directories whose names start with an underscore, such as `_components` or
115
+ `_utils`, are private folders. The plugin skips them and everything nested
116
+ inside them, so you can colocate components, hooks, and helpers next to the
117
+ routes that use them without creating routes. A `_components/page.tsx` file is
118
+ never routed.
119
+
120
+ ### Layouts
121
+
122
+ The plugin adapts React Router's matched child route to a `children` prop. A
123
+ layout can render that prop directly and does not need to import or render
124
+ `Outlet`:
125
+
126
+ ```tsx
127
+ import type { PropsWithChildren } from "react";
128
+
129
+ export default function Layout({ children }: PropsWithChildren) {
130
+ return <div className="app-shell">{children}</div>;
131
+ }
132
+ ```
133
+
134
+ ### Loading fallbacks
135
+
136
+ Add a `loading.jsx` or `loading.tsx` beside a page or layout to show a Suspense
137
+ fallback while that route subtree loads:
138
+
139
+ ```tsx
140
+ export default function Loading() {
141
+ return <p>Loading...</p>;
142
+ }
143
+ ```
144
+
145
+ A nested loading file creates a more specific boundary for that directory.
146
+
147
+ ### Not-found pages
148
+
149
+ Add a `not-found.jsx` or `not-found.tsx` file to handle unmatched URLs. The plugin generates
150
+ a catch-all child route, and the most specific matching route subtree's
151
+ not-found page is used:
152
+
153
+ ```text
154
+ src/pages/
155
+ not-found.tsx
156
+ users/
157
+ not-found.tsx
158
+ page.tsx
159
+ ```
160
+
161
+ The root not-found page handles unknown URLs generally, while `users/not-found.tsx`
162
+ handles unmatched URLs under `/users`.
163
+
164
+ ### Route error boundaries
165
+
166
+ Add an `error.jsx` or `error.tsx` file to handle errors from that route and its
167
+ descendants. The nearest route boundary is used, following React Router's error
168
+ handling behavior. Use React Router's `useRouteError` hook to read the error:
169
+
170
+ ```tsx
171
+ import { useRouteError } from "react-router";
172
+
173
+ export default function ErrorPage() {
174
+ const error = useRouteError();
175
+ return <main>Something went wrong: {String(error)}</main>;
176
+ }
177
+ ```
178
+
179
+ ### Loaders, actions, and route guards
180
+
181
+ Add a `loader.ts` / `loader.js` or `action.ts` / `action.js` beside the route
182
+ files and export a named handler. The generated React Router route calls these
183
+ handlers with React Router's loader/action arguments. Their returned values,
184
+ thrown errors, and redirect responses use React Router's normal data-router
185
+ behavior. A loader on a directory route runs when that route matches, including
186
+ when a descendant URL matches. An action runs only when its route is selected
187
+ as the submission target; a parent action does not automatically handle
188
+ submissions targeting child routes.
189
+
190
+ A loader module may also export `shouldRevalidate(args)` to control the route's
191
+ React Router `shouldRevalidate` option. It receives the same arguments as
192
+ `shouldRevalidate` and must synchronously return a boolean:
193
+
194
+ ```ts
195
+ import type { LoaderFunctionArgs, ShouldRevalidateFunctionArgs } from "react-router";
196
+
197
+ export function shouldRevalidate({ nextUrl, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
198
+ if (nextUrl.searchParams.get("refresh") === "never") return false;
199
+ return defaultShouldRevalidate;
200
+ }
201
+
202
+ export async function loader({ request }: LoaderFunctionArgs) {
203
+ return getDashboardData(request);
204
+ }
205
+ ```
206
+
207
+ Use a loader to redirect users who should not enter a protected route subtree.
208
+ For example, `src/pages/dashboard/loader.ts` can guard `/dashboard` and all of
209
+ its nested pages:
210
+
211
+ ```ts
212
+ import { redirect, type LoaderFunctionArgs } from "react-router";
213
+ import { getUserForRequest } from "../../lib/auth.server";
214
+
215
+ export async function loader({ request }: LoaderFunctionArgs) {
216
+ const user = await getUserForRequest(request);
217
+ if (!user) {
218
+ const returnTo = new URL(request.url).pathname;
219
+ throw redirect(`/sign-in?returnTo=${encodeURIComponent(returnTo)}`);
220
+ }
221
+ return null;
222
+ }
223
+ ```
224
+
225
+ Actions use the same pattern and receive React Router's `ActionFunctionArgs`:
226
+
227
+ ```ts
228
+ import { redirect, type ActionFunctionArgs } from "react-router";
229
+
230
+ export async function action({ request }: ActionFunctionArgs) {
231
+ const formData = await request.formData();
232
+ await updateProfile(formData);
233
+ return redirect("/profile");
234
+ }
235
+ ```
236
+
237
+ When a directory has both a page and a loader, that page is generated as an
238
+ index child route, while the loader remains on the directory route so it also
239
+ runs for descendant pages. Read the directory loader's result in the page with
240
+ React Router's `useRouteLoaderData`. The generated route ID is
241
+ `file-route:` followed by the directory path relative to the Vite project
242
+ root, with `/` separators:
243
+
244
+ ```tsx
245
+ import { useRouteLoaderData } from "react-router";
246
+
247
+ export default function DashboardPage() {
248
+ const data = useRouteLoaderData<{ message: string }>(
249
+ "file-route:src/pages/dashboard",
250
+ );
251
+ return <h1>{data?.message}</h1>;
252
+ }
253
+ ```
254
+
255
+ To get the type from the loader module instead of writing it by hand, use
256
+ `useRouteData` from `virtual:route-helpers`. See
257
+ [Typed params and loader data](#typed-params-and-loader-data).
258
+
259
+ The authentication and mutation functions above are application code. A
260
+ client-side route guard controls navigation and presentation; it is not a
261
+ security boundary. Protect data and mutations in the server/API layer as well,
262
+ and do not rely on a parent loader running before descendant loaders.
263
+
264
+ ### Middleware
265
+
266
+ Add a `middleware.ts` (or `.js`) file to run code before the loaders and
267
+ actions at or below its directory, for example to check a session, log
268
+ requests, or share data through React Router's `context`. Export a function,
269
+ or an array of functions, as `middleware` or as the default export:
270
+
271
+ ```ts
272
+ // src/pages/context.ts
273
+ import { createContext } from "react-router";
274
+
275
+ export const userContext = createContext<User | null>(null);
276
+ ```
277
+
278
+ ```ts
279
+ // src/pages/(app)/middleware.ts
280
+ import { redirect, type MiddlewareFunction } from "react-router";
281
+ import { userContext } from "../context";
282
+
283
+ export const middleware: MiddlewareFunction[] = [
284
+ async ({ request, context }, next) => {
285
+ const user = await getUser(request);
286
+ if (!user) throw redirect("/sign-in");
287
+ context.set(userContext, user);
288
+ await next();
289
+ },
290
+ ];
291
+ ```
292
+
293
+ ```ts
294
+ // src/pages/(app)/dashboard/loader.ts
295
+ import type { LoaderFunctionArgs } from "react-router";
296
+ import { userContext } from "../../context";
297
+
298
+ export function loader({ context }: LoaderFunctionArgs) {
299
+ return { user: context.get(userContext) };
300
+ }
301
+ ```
302
+
303
+ The plugin attaches the file to the directory's route through React Router's
304
+ route `middleware` option, so React Router's rules apply: parent middleware
305
+ runs before child middleware, `next()` runs the rest of the chain and the
306
+ loaders or action, and a function that doesn't call `next()` has it called
307
+ for it afterward. Throwing a redirect stops the navigation, and other thrown
308
+ errors render the nearest error file. Middleware runs on every navigation,
309
+ even when no route has a loader. React Router 8 supports route middleware
310
+ without a future flag.
311
+
312
+ React Router can't load middleware through a route's `lazy()` function, so
313
+ the generated route imports the file the first time its middleware runs.
314
+ `preloadRoute()` imports it ahead of time along with the route's other
315
+ modules.
316
+
317
+ Middleware runs in the browser here, like loaders and actions. As with route
318
+ guards, it isn't a security boundary: protect data in your server or API too.
319
+
320
+ ### Dynamic routes and route groups
321
+
322
+ Two pages must not resolve to the same URL. This includes pages with the same
323
+ path in separate route groups, such as `(auth)/sign-in/page.tsx` and
324
+ `(guest)/sign-in/page.tsx`. During development, the router displays a diagnostic
325
+ page listing the conflicting files; production builds fail with the same
326
+ diagnostic until the conflict is fixed. Dynamic parameter names do not make
327
+ otherwise identical patterns distinct (`users/[id]` and `users/[slug]`
328
+ conflict). Static and dynamic siblings such as `users/new` and `users/[id]`
329
+ are distinct; React Router's normal route ranking selects the static route
330
+ first.
331
+
332
+ ## Use the generated routes
333
+
334
+ Import `routes` from the virtual module and pass it to React Router:
335
+
336
+ ```tsx
337
+ import { createBrowserRouter } from "react-router";
338
+ import { RouterProvider } from "react-router";
339
+ import { routes } from "virtual:routes";
340
+
341
+ const router = createBrowserRouter(routes);
342
+
343
+ export default function App() {
344
+ return <RouterProvider router={router} />;
345
+ }
346
+ ```
347
+
348
+ ### Route manifest
349
+
350
+ Import `routeManifest` from `virtual:route-manifest` for a stable, nested
351
+ description of the scanned routes. Source files are relative to the Vite
352
+ project root and use forward slashes. Each node includes its URL `fullPath`,
353
+ the parameters introduced by that segment, any `optionalParams`, and its
354
+ recognized files:
355
+
356
+ ```ts
357
+ import { routeManifest } from "virtual:route-manifest";
358
+
359
+ console.log(routeManifest.children);
360
+ ```
361
+
362
+ The root node has `fullPath: "/"`; route-group nodes have no URL segment but
363
+ remain in the tree. React Router's generated `routes` remain the runtime source
364
+ of truth.
365
+
366
+ The development server watches the configured directory. Adding, removing,
367
+ renaming, or editing route files invalidates all generated virtual modules and
368
+ reloads the app without requiring a server restart.
369
+
370
+ ### Route tree inspection
371
+
372
+ The route tree can be inspected in Vite DevTools. This requires Vite 8.3 or
373
+ later and the DevTools package:
374
+
375
+ ```sh
376
+ npm install -D @vitejs/devtools
377
+ ```
378
+
379
+ Enable the dock in your Vite configuration:
380
+
381
+ ```ts
382
+ export default defineConfig({
383
+ devtools: true,
384
+ plugins: [routes()],
385
+ });
386
+ ```
387
+
388
+ Open the **Routes** dock to use the panel. It's development-only and needs
389
+ Vite DevTools enabled. In the panel you can:
390
+
391
+ - **Browse the route tree.** Each route shows its URL segment, full path, and
392
+ badges for its files: page, layout, loading, error, not-found (`404`),
393
+ loader, and action. You can filter by path, param, or file, collapse
394
+ branches, and move between routes with the arrow keys.
395
+ - **See route details.** Select a route to see its params, files, and
396
+ warnings. For a route with a loader or action, the **Data calls** section
397
+ shows how many calls it made, their average and slowest duration, errors,
398
+ and the last outcome, with a link to those calls in the timeline.
399
+ - **Open files in your editor.** Click any file path. This uses Vite's built-in
400
+ `/__open-in-editor` endpoint, so it opens the editor Vite detects or the one
401
+ named in `LAUNCH_EDITOR`.
402
+ - **Test a URL.** Enter a path such as `/users/42?tab=posts`. The panel shows
403
+ the matched routes, the params taken from the URL, and the page or
404
+ not-found page that renders. It also lists the layouts, loaders, loading
405
+ fallback, and error boundary that apply. Matching uses React Router's
406
+ `matchRoutes`, so the result agrees with the app. The matched routes are
407
+ highlighted in the tree.
408
+ - **Open a route in your app.** Route details have an **Open in app** button.
409
+ For routes with params, it's a short form with a field for each param
410
+ (optional params can be left empty, and a catch-all takes the rest of the
411
+ path). The panel remembers the values you used last for each route. A
412
+ button on each tree row does the same, and the URL tester has one for the
413
+ tested URL. The app tab you follow navigates with React Router, without a
414
+ page reload, and Back returns to where it was. If no app tab is connected,
415
+ the URL opens in a new browser tab. URLs are paths in your app; if your
416
+ router uses a `basename`, include it.
417
+ - **Spot route conflicts.** Pages that resolve to the same URL pattern are
418
+ listed at the top, with links to their files.
419
+ - **Catch missing files.** The warnings list at the top, and a `warn` badge in
420
+ the tree, flag:
421
+ - a route with a loader, action, or middleware but no `error` file at or
422
+ above it, so errors it throws show React Router's default error screen;
423
+ - a missing root `not-found` file (a hint when a root `error` file handles
424
+ unknown URLs instead);
425
+ - a loader or action file that doesn't export a function named `loader` or
426
+ `action`, or a middleware file with no default or `middleware` export.
427
+
428
+ Loaders the timeline measures at 500 ms or more get a hint as well.
429
+ Loading files only cover lazy page code, not loader time, so show pending
430
+ UI with `useNavigation()` in a layout instead. Warnings update when route
431
+ files change.
432
+ - **Follow the current page.** The **Current page** card shows the URL your
433
+ app is on, its params, and the page that renders it. The matched routes get
434
+ a green dot in the tree, and the card updates as the app navigates. With
435
+ several app tabs open, pick which one to follow. When a tab closes, it drops
436
+ out of the list.
437
+ - **See loader and action calls.** The **Timeline** tab lists each loader,
438
+ action, and middleware call your app makes, newest first. Middleware
439
+ entries show the time spent in the middleware itself, not counting what
440
+ `next()` runs. Each entry shows the route, URL, start time, duration, and
441
+ outcome: returned data, a redirect with its status and location, a returned
442
+ response, a thrown error, or `next()` for middleware that passed control
443
+ on. Calls of 500 ms or
444
+ more are highlighted. It also shows each `shouldRevalidate` decision, so
445
+ you can see when a loader was skipped. You can filter by call type, text,
446
+ or route. The dev server keeps the last 200 calls, so a panel opened later
447
+ still sees recent history.
448
+ - **Inspect a call.** Expand a timeline entry to see its route params, the
449
+ request, and for actions the submitted form fields or body. Returned data
450
+ appears as a collapsible JSON tree with a copy button. Responses show their
451
+ status, headers, and body, and errors show their name, message, and stack.
452
+ `shouldRevalidate` entries show the URL being left and the next URL, the
453
+ form method, and the action status.
454
+
455
+ The panel updates on its own when route files are added, removed, or renamed.
456
+ The dev server pushes changes over a Server-Sent Events stream instead of the
457
+ panel polling. If the dev server restarts, the panel reconnects by itself. The
458
+ refresh button reloads the data by hand.
459
+
460
+ The current page and the timeline come from a small client that
461
+ `virtual:routes` includes on the dev server only. It reports the app's URL and
462
+ wraps the generated loader, action, middleware, and `shouldRevalidate`
463
+ handlers. It sends these over Vite's HMR connection, and receives the panel's
464
+ **Open in app** requests over it. Handler results pass through unchanged. The
465
+ panel gets a capped copy of each call: up to 8 KB of returned data, and up to
466
+ 4 KB each for bodies and stacks. Circular references, `Map`, `Set`, and
467
+ `bigint` values are converted so serialization doesn't fail. Request and
468
+ response bodies are read from clones, so your handlers and React Router can
469
+ still read them. It tracks navigation by wrapping `history.pushState` and
470
+ `history.replaceState` and listening for `popstate`, and it also supports hash
471
+ URLs (`#/path`). Production builds include none of this code.
472
+
473
+ ### Build-time route warnings
474
+
475
+ `vite build` prints the same missing-file warnings the DevTools panel shows,
476
+ once per build, grouped by route:
477
+
478
+ ```text
479
+ vite-file-router found 2 route warnings:
480
+
481
+ /users
482
+ - This route has a loader but no error file at or above it, so errors it throws show React Router's default error screen. [no-error-boundary]
483
+ src/pages/users/loader.ts
484
+ - This loader file doesn't export a function named "loader", so the route loads no data. [missing-export]
485
+ src/pages/users/loader.ts
486
+ ```
487
+
488
+ Hints, such as a root `error` file handling unknown URLs in place of a
489
+ `not-found` page, only appear in the panel. The dev server doesn't print
490
+ warnings either; the panel shows them there.
491
+
492
+ Use the `warnings` option to change this:
493
+
494
+ ```ts
495
+ routes({ warnings: "error" }); // fail the build, for example in CI
496
+ routes({ warnings: false }); // don't check
497
+ routes({
498
+ warnings: { level: "error", ignore: ["no-not-found"] },
499
+ });
500
+ ```
501
+
502
+ The warning kinds are `no-error-boundary`, `no-not-found`, and
503
+ `missing-export`.
504
+
505
+ ### Typed route helpers
506
+
507
+ Import `routePath` from `virtual:route-helpers` to build paths for discovered
508
+ pages. The helper is compatible with React Router's `Link`, `NavLink`,
509
+ `navigate`, and `redirect` APIs. Dynamic parameter names and required values are
510
+ checked from the path literal; values are URL-encoded. Splat values use the
511
+ React Router `"*"` parameter and are encoded segment-by-segment so `/`
512
+ separators are preserved:
513
+
514
+ ```tsx
515
+ import { Link } from "react-router";
516
+ import { routePath } from "virtual:route-helpers";
517
+
518
+ export function UserLink({ userId }: { userId: string }) {
519
+ return (
520
+ <Link to={routePath("/users/:userId", { userId })}>
521
+ View user
522
+ </Link>
523
+ );
524
+ }
525
+
526
+ const docsPath = routePath("/docs/*", { "*": "guides/getting-started" });
527
+ const localizedDocsPath = routePath("/docs/:lang?", { lang: "en" });
528
+ const defaultDocsPath = routePath("/docs/:lang?");
529
+ ```
530
+
531
+ Static routes and routes with only optional parameters need no parameter object.
532
+ An optional parameter can also be omitted from the params object; its URL
533
+ segment is removed. Unknown route patterns and missing required or extra
534
+ parameters are rejected at runtime. Routes whose optional segment overlaps
535
+ another page's URL pattern are reported as conflicts. The generated
536
+ `routePatterns` export contains the discovered page paths.
537
+
538
+ ### Generated route types
539
+
540
+ In TypeScript projects, the plugin writes `src/route-types.d.ts` when the dev
541
+ server starts and at the start of each build. It counts a project as
542
+ TypeScript when the Vite project root has a `tsconfig.json` or
543
+ `jsconfig.json`. JavaScript projects without either file get no extra file.
544
+
545
+ The plugin rewrites the file whenever route files are added, removed, or
546
+ renamed, and leaves it alone when nothing changed. The file lists every
547
+ discovered page pattern and loader module. With it in your TypeScript project,
548
+ `routePath`, `routePatterns`, and the hooks below only accept routes that
549
+ exist, and your editor autocompletes them:
550
+
551
+ ```ts
552
+ routePath("/users/:userId", { userId }); // OK
553
+ routePath("/usres/:userId", { userId }); // Type error: unknown route pattern
554
+ ```
555
+
556
+ The `dts` option controls this. It defaults to `"auto"`, which is the behavior
557
+ above. A path, relative to the Vite project root and ending in `.d.ts`, always
558
+ writes the file there, even without a TypeScript config. `false` never writes
559
+ it:
560
+
561
+ ```ts
562
+ plugins: [routes({ dts: "types/route-types.d.ts" })];
563
+ plugins: [routes({ dts: false })];
564
+ ```
565
+
566
+ If your `tsconfig.json` isn't in the Vite project root, for example when Vite's
567
+ `root` points at a subfolder, set an explicit path.
568
+
569
+ Without the file, route patterns are typed as `string` and loader data as
570
+ `unknown`, the same as before. You can commit the file so types work before
571
+ the first dev run, or add it to `.gitignore`.
572
+
573
+ ### Typed params and loader data
574
+
575
+ `virtual:route-helpers` also exports two hooks that wrap React Router's hooks
576
+ with types taken from the route tree:
577
+
578
+ - `useRouteParams(pattern)` returns `useParams()`, typed for that pattern.
579
+ Required params are `string`, optional params are `string | undefined`, and
580
+ a splat is a `string` under the `"*"` key.
581
+ - `useRouteData(key)` returns `useRouteLoaderData()`, typed from the loader
582
+ module. The key is either a page pattern, which reads the loader in that
583
+ page's directory, or a loader route ID such as
584
+ `"file-route:src/pages/users/[userId]"`, which can read any matched loader,
585
+ including a parent's. It returns `undefined` when that route isn't matched,
586
+ just like React Router's hook.
587
+
588
+ ```tsx
589
+ // src/pages/users/[userId]/loader.ts
590
+ import type { LoaderFunctionArgs } from "react-router";
591
+
592
+ export async function loader({ params }: LoaderFunctionArgs) {
593
+ return { name: await getUserName(params.userId!) };
594
+ }
595
+
596
+ // src/pages/users/[userId]/page.tsx
597
+ import { useRouteData, useRouteParams } from "virtual:route-helpers";
598
+
599
+ export default function UserPage() {
600
+ const { userId } = useRouteParams("/users/:userId"); // { userId: string }
601
+ const data = useRouteData("/users/:userId"); // { name: string } | undefined
602
+ return <h1>{data?.name ?? userId}</h1>;
603
+ }
604
+ ```
605
+
606
+ The `RouteParams<"/users/:userId">`, `RouteLoaderData<key>`, `RoutePattern`,
607
+ and `RouteLoaderKey` types are exported from `@troyio/vite-file-router` for
608
+ your own helpers. Loader data is typed the same way as React Router's
609
+ `useRouteLoaderData<typeof loader>()`. JavaScript loader modules are typed as
610
+ `any`. Both hooks throw if they're passed a pattern or key the plugin didn't
611
+ discover.
612
+
613
+ ### Route preloading
614
+
615
+ `virtual:routes` exports `preloadRoute(to, basename?)`. It matches a URL
616
+ against the route tree and imports the page, layout, and loader modules of
617
+ every matched route, so the next navigation there doesn't wait for those
618
+ downloads. Error and action modules are left out, since they're only needed
619
+ after an error or a form submission. Importing modules doesn't run loaders.
620
+ Each module downloads once, however often you call it. The promise resolves to
621
+ `false` when no route matches the URL.
622
+
623
+ Call it when a user is likely to navigate, such as on link hover or focus:
624
+
625
+ ```tsx
626
+ import { Link, type LinkProps } from "react-router";
627
+ import { preloadRoute } from "virtual:routes";
628
+
629
+ export function PreloadLink(props: LinkProps & { to: string }) {
630
+ const preload = () => void preloadRoute(props.to);
631
+ return <Link {...props} onMouseEnter={preload} onFocus={preload} />;
632
+ }
633
+ ```
634
+
635
+ If the router uses a `basename`, pass it as the second argument. `to` also
636
+ accepts a partial location such as `{ pathname: "/users/42" }`.
637
+
638
+ ### TypeScript setup
639
+
640
+ Add Vite's client types and the package's virtual-module declaration to your
641
+ application TypeScript project. For example, create `src/vite-env.d.ts`:
642
+
643
+ ```ts
644
+ /// <reference types="vite/client" />
645
+ /// <reference types="@troyio/vite-file-router" />
646
+ ```
647
+
648
+ If `tsconfig.json` sets `compilerOptions.types`, include both `"vite/client"`
649
+ and `"@troyio/vite-file-router"` in that list. This lets TypeScript resolve
650
+ `virtual:routes`. Make sure the generated `src/route-types.d.ts`, or the path
651
+ you set with `dts`, is included by your `tsconfig.json`. Restart your editor's
652
+ TypeScript server if the declarations aren't picked up immediately.
653
+
654
+ ## Build
655
+
656
+ ```sh
657
+ npm run build
658
+ ```
659
+
660
+ ## Test
661
+
662
+ ```sh
663
+ npm test
664
+ ```