Roll your own file-based router in under 50 lines of code

cprecioso1 pts0 comments

Roll your own file-based router in under 50 lines of code | Wasp

Skip to main content<br>🦋 Launch Week #12 — TS Spec · Wasp goes fully TypeScript-native. Kickoff Mon, Jun 15 →

Nowadays, web frameworks come with ✨ magic ✨. Some more than others, but all of them have some. The magic is usually in the form of conventions: do things their way and the framework helps you out; wander off the happy path and you're on your own.

One of the main tricks they pull is quietly turning your folder structure into a config file. Create app/about-us/page.tsx in a Next.js app and https://localhost:3000/about-us magically starts working. Rename the file, and the URL changes with it. The framework is reading your file tree and turning it into a routing table.

That is called file-based routing , and it's usually magic your framework either hands you or it doesn't; and when it does, you rarely get to customize it, the conventions are baked in. Want a folder name to carry special meaning, or a logged-in.tsx/logged-out.tsx split per route? Your only option is to post a feature request and wait, or go without.

In Wasp, we really like explicitness, so we've resisted shipping a file-based router. That's changing now, but of course, we're not just adding the magic conventions and moving on. We're giving you a way to choose and design your own magic.

Wait, what's Wasp?​

Wasp is a batteries-included full-stack web framework for TypeScript. It covers your frontend, backend, and database as one cohesive whole.

And one of our core ideas is that the entry point to all this full-stack goodness is through an explicit Spec . Instead of hiding your app's structure behind implicit conventions, you declare its features (like routes, authentication, queries, cron jobs, etc) directly in code. Wasp then compiles and stitches these pieces together:

import { app, route, page } from "@wasp.sh/spec";

// `type: ref` means we just want to point to these files, not execute them now:

import MainPage from "./src/pages/MainPage" with { type: "ref" };

import UserPage from "./src/pages/LoginPage" with { type: "ref" };

export default app({

// Some example configurations, we'll skip them in the future:

name: "my-app",

wasp: { version: "^0.24.0" },

auth: { userEntity: "User", google: {} },

// The spec is where you declare your app's features:

spec: [

route("MainRoute", "/", page(MainPage)),

route("LoginRoute", "/login", page(UserPage, { authRequired: true })),

],

});

We chose this approach because explicit code is easier to reason about than hidden magic. This transparency makes the codebase easier to navigate for your team, and it gives AI code assistants a clear, structured map of your application to work with.

That explicitness is the whole philosophy, and it's exactly why we've deliberately never shipped file-based routing. We just didn't want to bake that pile of implicit conventions into the framework.

The Spec is just a program​

But here's the part that makes it exciting. We recently updated our spec so that it lives in a main.wasp.ts file. And instead of being a static JSON file that the framework reads, it is just a standard Node.js program , written in TypeScript. You can pull in libraries from npm, read from disk, call an API, use environment variables, anything! The only rule is that you export an app at the end. That's all we need.

So the Spec is explicit, but it's also programmable. And nothing says those route and page calls have to be typed out by hand.

That's the entire trick. You can write a function that walks your src/ folder and returns the same route and page objects you'd otherwise write manually. File-based routing stops being internal magic, and becomes a few lines of ordinary code that live in your repo, that you can read, and more importantly, customize.

So even though we never shipped file-based routing, "we didn't ship it" doesn't mean "you can't have it". You add it yourself, with exactly the conventions you want. You get the best of both worlds, and nobody has to argue about defaults. Sorry if you liked the arguing.

The simplest possible router​

Let's build that. Because the route list is just an array of objects, the simplest possible "router" is one we write out by hand:

// The `ref` helper is the dynamic version of `with { type: "ref" }` imports, so we'll use it to build programmatically.

import { app, page, route, ref } from "@wasp.sh/spec";

export default app({

spec: [

route(

"RootRoute",

"/",

page(ref({ importDefault: "RootPage", from: "./src/page.tsx" })),

),

route(

"AboutUsRoute",

"/about-us",

page(

ref({ importDefault: "AboutUsPage", from: "./src/about-us/page.tsx" }),

),

),

],

});

That's a functioning router already, but the unsatisfying part is that we had to type it out ourselves. So let's generate it instead, by looking at the project files.

The step up: generate the routes from the filesystem​

We'll pick a very simple convention: a page.tsx inside a folder makes it a...

file page wasp spec route from

Related Articles