Wasp's Programmable Spec: File-Based Routing Without the Magic
Most web frameworks treat file-based routing as internal magic: create app/about-us/page.tsx in Next.js, and /about-us just works. Rename the file, the URL changes. You get zero customization—unless you submit a feature request and wait.
Wasp, a batteries-included full-stack TypeScript framework, has always resisted this implicit convention. Instead, it uses an explicit Spec file (main.wasp.ts) where you declare routes, auth, queries, and cron jobs in code. That Spec is now programmable—a standard Node.js program that can import npm packages, read the filesystem, or call APIs. The only requirement: export an app object.
This unlocks a new pattern: you can write a function that walks your src/ folder and generates route objects automatically. File-based routing becomes 45 lines of ordinary code, fully customizable.
The Core Idea
The Spec is just an array of route() objects. You can generate that array from the filesystem. The simplest version:
import { page, ref, route } from "@wasp.sh/spec";
import { pascalCase } from "es-toolkit";
import { globSync } from "node:fs";
import * as path from "node:path";
export const fileBasedRoutes = (baseDir: string) => {
return globSync("**/page.tsx", { cwd: baseDir })
.sort()
.map((filePath) => {
const absoluteFilePath = path.resolve(baseDir, filePath);
const urlRoute = filePath
.split(path.sep)
.slice(0, -1)
.join("/");
const routeName = pascalCase(urlRoute) || "Root";
return route(
`${routeName}Route`,
"/" + urlRoute,
page(
ref({
importDefault: `${routeName}Page`,
from: absoluteFilePath,
}),
),
);
});
};
Wiring it into your app is one line:
// main.wasp.ts
import { app } from "@wasp.sh/spec";
import { fileBasedRoutes } from "./lib/file-based-routes.wasp";
export default app({
// ... other config
spec: [
fileBasedRoutes("src"),
// other spec items
],
});
Because the generated objects are standard route and page objects, all Wasp features still work—type-safe links, autocomplete, and type checking.
Custom Conventions: Route Groups, Auth, Prerender, Dynamic Segments
The real power is customization. Want route groups (folders that don't appear in the URL)? Add a one-line filter:
const ROUTE_GROUP_REGEX = /^\(.*\)$/;
const urlRoute = filePath
.split(path.sep)
.slice(0, -1)
.filter((part) => !ROUTE_GROUP_REGEX.test(part))
.join("/");
Now src/(marketing)/about-us/page.tsx serves /about-us.
Want auth-required pages? Detect an (auth) folder:
const isAuth = filePath.includes("(auth)");
return route(
`${routeName}Route`,
"/" + urlRoute,
page(
ref({ importDefault: `${routeName}Page`, from: absoluteFilePath }),
{ authRequired: isAuth },
),
// ...
);
Prerender? Same pattern with (prerender). Dynamic segments? Use Next.js-style brackets:
const DYNAMIC_SEGMENT_REGEX = /^\[(.*)\]$/;
const OPTIONAL_SEGMENT_REGEX = /^\[\[(.*)\]\]$/;
// ...
.map((part) => part.replace(OPTIONAL_SEGMENT_REGEX, ":$1?"))
.map((part) => part.replace(DYNAMIC_SEGMENT_REGEX, ":$1"))
src/products/[productId]/page.tsx becomes /products/:productId.
The Full 45-Line Router
Putting it all together:
import { page, ref, route } from "@wasp.sh/spec";
import { pascalCase } from "es-toolkit";
import { globSync } from "node:fs";
import * as path from "node:path";
const ROUTE_GROUP_REGEX = /^\(.*\)$/;
const DYNAMIC_SEGMENT_REGEX = /^\[(.*)\]$/;
const OPTIONAL_SEGMENT_REGEX = /^\[\[(.*)\]\]$/;
export const fileBasedRoutes = (baseDir: string) => {
return globSync("**/page.tsx", { cwd: baseDir })
.sort()
.map((filePath) => {
const absoluteFilePath = path.resolve(baseDir, filePath);
const isPrerender = filePath.includes("(prerender)");
const isAuth = filePath.includes("(auth)");
const urlRoute = filePath
.split(path.sep)
.slice(0, -1)
.filter((part) => !ROUTE_GROUP_REGEX.test(part))
.map((part) => part.replace(OPTIONAL_SEGMENT_REGEX, ":$1?"))
.map((part) => part.replace(DYNAMIC_SEGMENT_REGEX, ":$1"))
.join("/");
const routeName = pascalCase(urlRoute) || "Root";
return route(
`${routeName}Route`,
"/" + urlRoute,
page(
ref({
importDefault: `${routeName}Page`,
from: absoluteFilePath,
}),
{ authRequired: isAuth },
),
{ prerender: isPrerender },
);
});
};
That's it. Your conventions, your code, no framework magic. The Spec is explicit and programmable—exactly what Wasp's philosophy promises.
Why This Matters for Developers
This approach gives you the convenience of file-based routing without locking you into a framework's opinionated defaults. You can evolve your conventions as your project grows, and the logic stays in your repo—transparent, testable, and AI-friendly. If you've ever been frustrated by a framework's routing limitations, this pattern is a blueprint for escaping them.
Next Steps
If you're using Wasp (version ^0.24.0 or later), drop the code above into your project and customize the conventions to your team's needs. If you're not using Wasp, the idea of a programmable spec is worth studying—it's a clean separation of concerns that could inspire your own tooling.

