Router-neutral file-system routing.
This package owns the machinery of file routing — scanning a directory, applying a filename convention and producing a neutral route manifest — while routers own the shapes: each router ships a small emission adapter that turns the manifest into its own route definitions, and each bundler ships a delivery adapter that materializes the manifest into code.
| Piece | Owner |
|---|---|
| Scanning, filename convention, neutral route manifest | filesystem-routing |
Nesting + (group) stripping |
filesystem-routing/tree |
| Vite delivery (virtual module, HMR, code splitting, build inputs) | filesystem-routing/vite |
RouteDefinition emission for Solid Router (code splitting, route CSS) |
filesystem-routing/solid-router |
Request dispatch for GET/POST routes, as fetch middleware |
filesystem-routing/api |
Nothing here is Solid-specific: the core is bundler-agnostic — it never imports
Vite — the Vite adapter is router-agnostic, and the conventions are the ones
proven by SolidStart. The manifest is served from virtual:file-routes, or
from whatever id you pass as fileRoutes({ moduleId }).
// vite.config.ts
import { fileRoutes } from "filesystem-routing/vite";
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";
export default defineConfig({
// `extensions` makes vite-plugin-solid also compile the `?pick=` route
// modules this plugin emits (their ids end in a query string)
plugins: [solid({ extensions: [".jsx", ".tsx"] }), fileRoutes()]
});// src/app.tsx
import { pageRoutes } from "virtual:file-routes";
import clientAssets from "virtual:solid-manifest/client";
import { createRouter } from "@solidjs/router";
import { fileRoutes } from "filesystem-routing/solid-router";
const Router = createRouter({ routes: fileRoutes(pageRoutes, { assets: clientAssets }) });
export const App = () => <Router>{props => <>{props.children}</>}</Router>;The emission adapter never imports a virtual module itself — the app does,
so the adapter resolves without the plugin, custom moduleIds work, and
nothing needs excluding from dependency prebundling. The same goes for the
client asset manifest behind the route-CSS lifecycle: assets takes a map
keyed by module source path (or a resolver over one) — vite-plugin-solid
apps pass virtual:solid-manifest/client, other toolchains pass their
equivalent, and without it components render unwrapped (in dev the map is
empty and Vite's own client manages CSS). With assets, each route's
stylesheets are acquired on mount and released on route leave through the
runtime's ref-counted acquireAsset, so styles from a left route are
removed instead of accumulating.
Route modules live in src/routes (configurable via fileRoutes({ dir })).
A module is a page when it has a default export, and may export a route
config object:
// src/routes/blog/[id].tsx
import type { RouteDefinition } from "@solidjs/router";
export const route = {
preload: ({ params }) => loadPost(params.id)
} satisfies RouteDefinition;
export default function Post() {
return <h1>Post</h1>;
}Two conventions ship in the box. Both produce the same neutral manifest and
share the same module convention (default export is a page, route config
export, httpMethods handlers), so they are equally capable — only the
filenames differ.
The convention proven by SolidStart:
| File | Path |
|---|---|
index.tsx |
/ |
about.tsx |
/about |
blog/[id].tsx |
/blog/:id |
blog/[[page]].tsx |
/blog/:page? |
docs/[...path].tsx |
/docs/*path |
(marketing)/about.tsx |
/about, nested in the (marketing) group |
Nested layouts come from pairing a file with a directory: blog.tsx is the
layout for everything in blog/.
The convention proven by Remix v2 and carried forward by React Router's
@react-router/fs-routes — . delimiters instead of directories:
| File | Path |
|---|---|
_index.tsx |
/ |
about.tsx |
/about |
concerts.trending.tsx |
/concerts/trending |
concerts.$city.tsx |
/concerts/:city |
concerts.($page).tsx |
/concerts/:page? |
files.$.tsx |
/files/*splat |
_auth.login.tsx |
/login, nested in the _auth.tsx pathless layout |
concerts_.mine.tsx |
/concerts/mine, escaping the concerts.tsx layout |
[sitemap.xml].tsx |
/sitemap.xml — brackets escape special characters |
concerts.tsx is the layout for every concerts.* file; a top-level folder
routes through its route.tsx module and co-locates everything else in the
folder without routing it. Pass the router to the plugin:
import { FlatFileSystemRouter } from "filesystem-routing";
import { fileRoutes } from "filesystem-routing/vite";
import { resolve } from "node:path";
fileRoutes({
router: new FlatFileSystemRouter({
dir: resolve("src/routes"),
extensions: ["js", "jsx", "ts", "tsx"]
})
});Two departures from Remix: the catch-all param is named (params.splat
rather than params["*"]), and optional static segments ((en).about.tsx)
are rejected — the neutral pattern language has no representation for them.
Route modules can answer requests as well as render. Turn on httpMethods
(off by default — a client-only manifest has no use for handlers) and
uppercase exports become request handlers:
// src/routes/api/posts/[id].ts
export function GET(event) {
return Response.json(loadPost(event.params.id));
}
export function DELETE(event) {
deletePost(event.params.id);
return new Response(null, { status: 204 });
}The scanner turns each handler export into a lazy ref on the entry —
$GET: { src, pick: ["GET"] }, $DELETE: … — with the same code splitting
pages get:
- A module with handlers but no default export routes without being a page:
{ path: "/api/posts/:id", page: false, $GET, $DELETE }. - A module can be both. A page with a
GETexport gets$componentand$GET, and handler exports are excluded from the component ref's picks, so handler code never reaches the client bundle. - A lone
GETalso answersHEAD: a$HEADref aliasing theGETexport is added unless the module exports its own. - The recognized set is
HEAD/GET/POST/PUT/DELETE/PATCH/OPTIONS; pass an array instead oftrueto change it.
Dispatch ships as fetch-style middleware — filesystem-routing/api matches
requests against the manifest's handler refs and composes into any
(request, next) chain, an SSR handler's middleware option included:
// src/middleware.ts
import routes from "virtual:file-routes";
import { createAPIHandler } from "filesystem-routing/api";
export default [createAPIHandler(routes)];Handlers receive the request event of the surrounding scope (the storage
provideRequestEvent establishes — pass getEvent to dispatch outside
one), with the matched params written onto it. Unmatched requests (no
route, or no handler for the method) advance the chain; HEAD falls back
to the GET handler; returned strings and JSON values are coerced into
responses. A GET handler may decline by returning undefined: a module
that is also a page falls through to the chain (its component renders
instead), a handler-only module answers 404. createAPIMatcher exposes the
matching alone for servers with their own dispatch shape.
Frameworks typically enable httpMethods on the
server environment's router only (SolidStart pairs it with components: false in SPA mode, so the server manifest routes requests without shipping
page modules) — see the per-environment routers option below.
Conventions plug in at two independent seams, so a custom scheme picks the level it needs:
toPath— the filename convention: maps a route file (relative todir, extension stripped, e.g./blog/[id]) to a route path in the neutral pattern language (:param,:param?,*rest,(group)), orundefinedto skip the file. Layout nesting is encoded in the paths themselves:buildRouteTreenests entries by path prefix (a layout at/blogwraps a page at/blog/), and(group)segments nest without contributing URL — which is also how pathless layouts and layout escapes are expressed.toRoute— the module convention: maps a source file to a manifest entry, deciding what makes a file a route and which refs it carries. The built-in one analyzes exports (default export,routeconfig, HTTP handlers); a custom one can key off anything.
Pass either as config for one-off tweaks:
import { PageFileSystemRouter } from "filesystem-routing";
import { fileRoutes } from "filesystem-routing/vite";
fileRoutes({
toPath: routeFile => (routeFile.endsWith(".page") ? routeFile.slice(0, -5) : undefined)
});Or subclass for a full scheme — FlatFileSystemRouter is the model: it
extends PageFileSystemRouter, overrides only toPath, and everything else
(scanning, watching, module analysis, the tree, type generation) is
inherited. A convention that also changes what a route module is overrides
toRoute; a convention that changes what gets scanned overrides glob() on
BaseFileSystemRouter.
The scanner produces flat RouteManifestEntry objects:
{
path: "/blog/:id", // neutral pattern language
page: true,
$component: { src, pick }, // lazy module ref → code-split dynamic import
$$route: { src, pick } // eager module ref → static import
}The Vite adapter serializes the manifest into the virtual module, turning
$-prefixed refs into dynamic imports and $$-prefixed refs into static
imports, each tree-shaken down to the picked exports. It serves two views of
the same entries:
import routes, { pageRoutes } from "virtual:file-routes";routes is the flat manifest; pageRoutes is the page entries nested by path
with (group) segments stripped, so emission adapters don't each reimplement
the tree (buildRouteTree from filesystem-routing/tree is the same
function, for consumers holding only a flat manifest). An emission adapter is
a function taking manifest entries and returning the router's shape — see
fileRoutes in filesystem-routing/solid-router for Solid Router's, an
adapter other routers can mirror. Adapters take the manifest as an argument
rather than importing the virtual module, so they work standalone and with
custom module ids.
Add /// <reference types="filesystem-routing/types" /> to type the import.
A manifest is a runtime value, so a router that derives types from its route
table gets nothing from it — @solidjs/router's RoutePaths degrades to any
the moment the table is a RouteDefinition[] rather than a tuple. Turn on
types and the plugin writes a declaration in which the manifest is a
literal tuple, regenerating it as routes come and go:
fileRoutes({ types: "file-routes.d.ts" })Reference the generated file instead of filesystem-routing/types — it is
self-contained, and two declarations of the same module conflict. Emission
adapters then have to preserve the tuple on the way through, which means a
mapping typed over its input rather than a plain .map:
declare function toRoutes<const T extends readonly Entry[]>(
entries: T
): { [K in keyof T]: RouteFrom<T[K]> };Frameworks with several Vite environments can serve a different router (and convention) per environment, and have the plugin take every route module as a build entry for the browser one:
fileRoutes({
routers: {
// the browser routes and renders
client: new PageFileSystemRouter({ dir, extensions }),
// the server also answers `GET`/`POST` exports, and in SPA mode routes
// without shipping components
ssr: new PageFileSystemRouter({ dir, extensions, httpMethods: true, components: ssr })
},
buildInputs: "client"
});| Option | What it does |
|---|---|
httpMethods |
emit $GET, $POST, … refs for uppercase handler exports; a module with handlers but no default export routes without being a page |
components |
set false to route without emitting $component refs, keeping page modules out of that environment's bundle |
buildInputs |
environments whose build takes every code-split route module as an entry |
moduleId |
the id the manifest is served from |
optimizeDepsExclude |
escape hatch for packages that import the virtual module, kept out of dep prebundling (defaults to []) |
types |
write a declaration typing the manifest as a literal tuple, kept in step with the route directory |
The design comes from Vinxi's file-system
router by Nikhil Saraf, which powered SolidStart through 1.x: the
BaseFileSystemRouter with pluggable toPath/toRoute, and above all the
insight that the manifest should carry inert module refs — $-prefixed keys
becoming dynamic imports, $$-prefixed keys becoming static ones — so any
router and any bundler can meet at the same seam. This package extracts that
approach into a standalone library, adding the nested tree view, the flat
convention, and literal-tuple type generation. The filename conventions are
the ones proven by SolidStart and
Remix v2.