@zomme/bun-plugin-tsr
v0.2.1
Published
Bun plugin for TanStack Router route generation with file-based routing
Maintainers
Readme
@zomme/bun-plugin-tsr
Bun plugin for TanStack Router route generation with file-based routing.
Features
- Automatic route tree generation from file structure
- Watch mode for development (auto-regenerates on file changes)
- Configurable via
bunfig.toml - Supports multiple root directories
- Full TypeScript support
Installation
bun add -d @zomme/bun-plugin-tsrInstall peer dependencies:
bun add @tanstack/react-router
bun add -d @tanstack/router-generatorUsage
Basic Setup
Add the plugin to your bunfig.toml:
[serve.static]
plugins = ["@zomme/bun-plugin-tsr"]Or use it programmatically:
import tsr from "@zomme/bun-plugin-tsr";
Bun.build({
entrypoints: ["./src/index.tsx"],
plugins: [tsr],
});Configuration
Configure via bunfig.toml:
[plugins.tsr]
rootDirectory = "src"
routesDirectory = "./routes"
generatedRouteTree = "routeTree.gen.ts"
quoteStyle = "double"
routeFileIgnorePattern = ".test."Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| rootDirectory | string \| string[] | "src" | Root directory for routes |
| routesDirectory | string | "./routes" | Routes directory relative to root |
| generatedRouteTree | string | "routeTree.gen.ts" | Output file name |
| quoteStyle | "single" \| "double" | "double" | Quote style in generated code |
| routeFileIgnorePattern | string | ".test." | Pattern to ignore route files |
Directory Structure
Create routes using file-based routing:
src/
├── routes/
│ ├── __root.tsx # Root layout
│ ├── index.tsx # / (home page)
│ ├── about.tsx # /about
│ ├── dashboard/
│ │ ├── index.tsx # /dashboard
│ │ ├── settings.tsx # /dashboard/settings
│ │ └── $userId.tsx # /dashboard/:userId (dynamic)
│ └── posts/
│ ├── index.tsx # /posts
│ └── $postId.tsx # /posts/:postId (dynamic)
├── routeTree.gen.ts # Generated route tree (auto)
└── main.tsxRoute File Naming Conventions
| File Name | Route Path | Description |
|-----------|------------|-------------|
| index.tsx | / | Index route |
| about.tsx | /about | Static route |
| $userId.tsx | /:userId | Dynamic parameter |
| posts.$postId.tsx | /posts/:postId | Nested dynamic |
| __root.tsx | - | Root layout |
| -components/ | - | Private folder (ignored) |
Example
Root Layout
// src/routes/__root.tsx
import { Outlet, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({
component: RootComponent,
});
function RootComponent() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/dashboard">Dashboard</Link>
</nav>
<main>
<Outlet />
</main>
</div>
);
}Index Route
// src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
return <h1>Welcome Home</h1>;
}Dynamic Route
// src/routes/posts/$postId.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$postId")({
component: PostPage,
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
return { post };
},
});
function PostPage() {
const { post } = Route.useLoaderData();
return <article>{post.title}</article>;
}App Entry Point
// src/main.tsx
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen";
const router = (import.meta.hot.data.router ??= createRouter({ routeTree }));
router.update({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const root = (import.meta.hot.data.root ??= createRoot(document.getElementById("root")!));
root.render(<RouterProvider router={router} />);
import.meta.hot.accept();Important: The plugin appends
import.meta.hot.accept()to the generatedrouteTree.gen.tsso it acts as an HMR boundary. When any route file changes, Bun re-evaluates the route tree, which bubbles up to your entry point (also self-accepting). On re-evaluation,??=returns the existing router androuter.update({ routeTree })patches it with the new route tree. You must:
- Persist the router with
import.meta.hot.data.routerso it survives re-evaluations.- Call
router.update({ routeTree })right after — on first load this is a no-op, on HMR it patches the existing router with the re-imported route tree.Without this, editing route files will cause the page to crash or full-reload.
HMR Caveats
__root.tsx and module-level constructors
Because the generated routeTree.gen.ts self-accepts HMR, editing any
route file causes Bun to re-evaluate the route tree, which re-imports every
route module — including __root.tsx.
If __root.tsx (or a module it imports) calls a constructor at the top
level (e.g. new QueryClient()), the re-evaluation cascade can leave
that dependency in an inconsistent state, producing errors like:
TypeError: QueryClient is not a constructorHow to fix:
Persist instances with
import.meta.hot.data— the same pattern used for the router. This skips the constructor on re-evaluation:// helpers/query-client.ts import { QueryClient } from "@tanstack/react-query"; export const queryClient = (import.meta.hot.data.queryClient ??= new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } }));Move providers to the entry point — keep
__root.tsxfree of imports from libraries that export classes/constructors used at module scope. WrappingRouterProviderwithQueryClientProviderin your entry point removes the problematic import from__root.tsxentirely:// main.tsx import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./helpers/query-client"; root.render( <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> </QueryClientProvider>, );
Rule of thumb: any singleton created with
newthat__root.tsxdepends on should be persisted viaimport.meta.hot.data.xxx ??=to survive HMR re-evaluations.
Watch Mode
In development (NODE_ENV !== "production"), the plugin automatically watches for file changes:
- Create/Delete: Route files are detected and the route tree is regenerated
- Update: Content changes are handled by Bun HMR — the route tree re-evaluates
automatically via its
import.meta.hot.accept()boundary
# Development - watch mode enabled
bun dev
# Production - no watch mode
NODE_ENV=production bun buildMultiple Root Directories
Support multiple entry points:
[plugins.tsr]
rootDirectory = ["apps/web/src", "apps/admin/src"]Generated Route Tree
The plugin generates a routeTree.gen.ts file:
// This file is auto-generated by @zomme/bun-plugin-tsr
import { Route as rootRoute } from "./routes/__root";
import { Route as IndexRoute } from "./routes/index";
import { Route as AboutRoute } from "./routes/about";
import { Route as DashboardIndexRoute } from "./routes/dashboard/index";
const routeTree = rootRoute.addChildren([
IndexRoute,
AboutRoute,
DashboardIndexRoute,
]);
export { routeTree };How It Works
- Scan: Plugin scans
routesDirectoryfor route files - Parse: Analyzes file names for route patterns
- Generate: Creates route tree with proper nesting
- Watch: In dev mode, watches for changes and regenerates
Requirements
- Bun >= 1.0.0
@tanstack/react-router>= 1.0.0@tanstack/router-generator>= 1.0.0
License
MIT
