In Express.js, you define routes imperatively: app.get('/users/:id', handler). In Django, you write URL patterns in urls.py. In Rails, you configure routes.rb. In Laravel, you fill web.php. Every framework has its own routing DSL, its own syntax, its own file that grows into an unmanageable mess as the application scales.
FLIN has no routing file. The app/ directory IS the route table. Create a file called app/about.flin and the URL /about exists. Create app/api/users.flin and /api/users is live. Create app/blog/[slug].flin and every /blog/anything URL is handled. Delete the file and the route disappears.
This is not a novel idea -- Next.js, Nuxt, and SvelteKit all use file-based routing. But FLIN takes it further by making file-based routing the ONLY way to define routes. There is no escape hatch, no imperative alternative, no router.add() function. The file system is the single source of truth, and the framework is designed around that constraint.
The Directory Convention
A FLIN application's routing structure lives entirely in the app/ directory:
my-app/
app/
index.flin -> GET /
about.flin -> GET /about
pricing.flin -> GET /pricing
_middleware.flin -> Applies to all routesapi/ _middleware.flin -> Applies to /api/* users.flin -> /api/users (route blocks) users/ [id].flin -> /api/users/:id (route blocks)
blog/ index.flin -> GET /blog [slug].flin -> GET /blog/:slug
admin/ _middleware.flin -> Auth check for /admin/* index.flin -> GET /admin settings.flin -> GET /admin/settings
products/ [category]/ index.flin -> GET /products/:category [id].flin -> GET /products/:category/:id [...slug].flin -> GET /products/* (catch-all)
public/ logo.png -> /logo.png (static) styles.css -> /styles.css (static) ```
The rules are simple and predictable:
1. index.flin maps to the directory's root path.
2. name.flin maps to /name.
3. [param].flin maps to a dynamic segment.
4. [...param].flin maps to a catch-all segment.
5. _middleware.flin is never a route -- it applies middleware to the directory.
6. Nested directories create nested paths.
How the Router Builds the Route Table
At startup, the FLIN runtime scans the app/ directory recursively and builds a trie-based route table. The scan happens once, and in development mode, a file watcher updates the table when files are added or removed.
pub struct Router {
root: TrieNode,
}pub struct TrieNode {
/// Static children: "about" -> node, "api" -> node
children: HashMap
/// Dynamic child: [id] -> node (at most one)
dynamic: Option<(String, Box
/// Catch-all: [...slug] -> handler catch_all: Option<(String, RouteHandler)>,
/// Handler for this exact path
handler: Option
/// Middleware chain for this path and descendants
middleware: Vec
When a request arrives for /api/users/42, the router walks the trie: root -> api (static match) -> users (static match) -> 42 (dynamic match against [id]). The dynamic segment's name (id) and value (42) are captured in params.id.
The trie structure means route matching is O(k) where k is the number of path segments -- not O(n) where n is the total number of routes. An application with 500 routes matches just as fast as one with 5 routes.
Dynamic Segments
Dynamic segments are denoted by square brackets in the filename: [id].flin, [slug].flin, [category].flin. The name inside the brackets becomes the parameter name accessible via params.
// app/api/users/[id].flinroute GET { user = User.find(params.id) if user == none { return error(404, "User not found") } user }
route PUT { user = User.find(params.id) user.name = body.name user.email = body.email save user user }
route DELETE { user = User.find(params.id) delete user { success: true } } ```
Multiple dynamic segments work naturally through directory nesting:
// app/products/[category]/[id].flin
// Matches: /products/electronics/42product = Product.where(category == params.category).find(params.id) ```
Catch-All Routes
The [...param] syntax captures the entire remaining path. This is useful for documentation sites, CMS pages, or any route where the depth is variable.
// app/docs/[...path].flin
// Matches: /docs/getting-started
// /docs/api/reference/users
// /docs/guides/deployment/docker/composepath_segments = params.path.split("/") doc = Document.where(slug == params.path).first
{if doc}
{doc.title}
Page Not Found
{/if} ```The catch-all value in params.path is the full remaining path as a string: "guides/deployment/docker/compose". Split it if you need individual segments.
Route Priority
When multiple routes could match a path, FLIN follows a strict priority order:
1. Static matches win over dynamic matches. /api/users/me matches app/api/users/me.flin before app/api/users/[id].flin.
2. Dynamic matches win over catch-all matches. /products/shoes matches app/products/[category].flin before app/products/[...slug].flin.
3. Longer static prefixes win over shorter ones.
4. Index files match the directory root: app/blog/index.flin handles /blog, not app/blog.flin.
fn resolve_priority(candidates: &[RouteMatch]) -> &RouteMatch {
candidates.iter()
.max_by_key(|m| {
let static_score = m.static_segments * 1000;
let dynamic_score = m.dynamic_segments * 100;
let depth_score = m.depth * 10;
let index_bonus = if m.is_index { 5 } else { 0 };
static_score + dynamic_score + depth_score + index_bonus
})
.unwrap()
}This scoring system means you never need to worry about route order. Unlike Express, where the order of app.get() calls determines which handler runs first, FLIN's file-based routing has deterministic, predictable resolution based on specificity.
View Routes vs API Routes
FLIN distinguishes between two types of routes based on their content:
View routes contain HTML-like template sections. They serve text/html responses and are meant for browser rendering:
// app/about.flin -- this is a view route
title = "About Us"We build things.{title}
API routes contain route blocks with HTTP methods. They serve application/json responses:
// app/api/status.flin -- this is an API routeroute GET { { status: "healthy", uptime: server_uptime(), version: "1.0.0" } } ```
A single file can contain both view content and route blocks, making it possible to handle both browser and API requests in one place. But in practice, the api/ directory contains API routes and everything else contains view routes. The convention is enforced by team practice, not by the runtime.
Middleware Inheritance
Middleware files named _middleware.flin apply to every route in their directory and all subdirectories. This creates a natural inheritance chain:
Request: GET /admin/users/42
|
+-> app/_middleware.flin (logging, request ID)
|
+-> app/admin/_middleware.flin (auth check)
|
+-> app/admin/users/[id].flin (handler)Each middleware can call next() to pass control to the next middleware or handler, or return a response to short-circuit the chain:
// app/admin/_middleware.flinmiddleware { if session.user == none { redirect("/login") }
user = User.where(email == session.user).first if user == none || user.role != "Admin" { redirect("/") }
request.user = user next() } ```
This middleware runs before every route under /admin/. If the user is not authenticated or not an admin, they are redirected. If they pass both checks, the user object is attached to the request and available in every handler downstream.
The public/ Directory
Files in public/ are served as static assets without passing through the FLIN runtime. They have no middleware, no session access, and no dynamic behavior. This is intentional -- static assets should be served as fast as possible.
The server adds appropriate Content-Type headers based on file extension and sets Cache-Control: public, max-age=86400 for production builds. In development, caching is disabled to ensure changes are immediately visible.
Building the Route Table: Implementation
The route table construction is a single recursive function that runs at startup:
fn scan_routes(dir: &Path, prefix: &str, router: &mut Router) -> Result<(), ScanError> {
let mut entries: Vec<_> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.file_name());for entry in entries { let name = entry.file_name().to_string_lossy().to_string(); let path = entry.path();
if path.is_dir() { if name == "public" { continue; } // Skip static dir let segment = if name.starts_with('[') { format!(":{}", name.trim_matches(&['[', ']'][..])) } else { name.clone() }; let new_prefix = format!("{}/{}", prefix, segment); scan_routes(&path, &new_prefix, router)?; } else if name.ends_with(".flin") { if name == "_middleware.flin" { router.add_middleware(prefix, compile_middleware(&path)?); } else { let route_name = name.trim_end_matches(".flin"); let route_path = if route_name == "index" { prefix.to_string() } else { format!("{}/{}", prefix, route_name) }; router.add_route(&route_path, compile_handler(&path)?); } } } Ok(()) } ```
The function walks the directory tree, converts filenames to route segments, compiles each .flin file into a handler, and registers it with the router. The entire process takes less than 50 milliseconds for a typical application with 100 routes.
Why File-Based Routing Wins
The benefits of file-based routing compound as an application grows:
Discoverability. New developers understand the URL structure by looking at the file tree. There is no routing file to decode, no regex patterns to parse, no middleware ordering to untangle.
Colocation. The code that handles /api/users/:id lives in app/api/users/[id].flin. The relationship between URL and code is always 1:1.
Refactoring. Moving a route from /api/v1/users to /api/v2/users is a file system operation: mv app/api/v1/users.flin app/api/v2/users.flin. No code changes required.
Elimination of dead routes. In Express, a route definition in routes.js might point to a handler that was deleted months ago. In FLIN, if the file exists, the route exists. If the file is deleted, the route is gone.
The trade-off is that you cannot define routes dynamically at runtime. For the overwhelming majority of web applications, this is not a limitation -- it is a feature. Dynamic route registration is a source of bugs, security vulnerabilities, and cognitive overhead that most applications never need.
In the next article, we explore how FLIN's route blocks combine backend logic and frontend views in a single file, eliminating the traditional separation between API server and web server.
---
This is Part 97 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [96] FLIN's Embedded HTTP Server - [97] File-Based Routing in FLIN (you are here) - [98] API Routes: Backend and Frontend in One File - [99] Auto JSON and Form Body Parsing