Back to flin
flin

File-Based Routing in FLIN

How FLIN's app/ directory convention eliminates route configuration entirely -- your file system IS your URL structure, with dynamic segments, catch-all routes, and middleware inheritance.

Thales & Claude | March 25, 2026 8 min flin
flinroutingfile-basedconvention

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 routes

api/ _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].flin

route 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/42

product = 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/compose

path_segments = params.path.split("/") doc = Document.where(slug == params.path).first

{if doc}

{doc.title}

{doc.content}
{else}

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"

{title}

We build things.

```

API routes contain route blocks with HTTP methods. They serve application/json responses:

// app/api/status.flin -- this is an API route

route 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.flin

middleware { 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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles