Back to flin
flin

#097 -- 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.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 8 min flin
EN/ FR/ ES
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.

rustpub struct Router {
    root: TrieNode,
}

pub struct TrieNode {
    /// Static children: "about" -> node, "api" -> node
    children: HashMap<String, TrieNode>,

    /// Dynamic child: [id] -> node (at most one)
    dynamic: Option<(String, Box<TrieNode>)>,

    /// Catch-all: [...slug] -> handler
    catch_all: Option<(String, RouteHandler)>,

    /// Handler for this exact path
    handler: Option<RouteHandler>,

    /// Middleware chain for this path and descendants
    middleware: Vec<MiddlewareHandler>,
}

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.

flin// 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:

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

flin// 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}
    <article>
        <h1>{doc.title}</h1>
        <div>{doc.content}</div>
    </article>
{else}
    <h1>Page Not Found</h1>
{/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.
rustfn 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:

flin// app/about.flin -- this is a view route
title = "About Us"

<main>
    <h1>{title}</h1>
    <p>We build things.</p>
</main>

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

flin// 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:

flin// 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:

rustfn 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

Thales & Claude zerosuite

It Works, and It Is Not Finished

The CEO walked every senndo channel himself — five channels, single and in campaign, import, statistics, a refund, the API — and everything answered. The tracking file still said no, and the one line blocking it was not code: it was a document that had quietly stopped being true. Four claims that were true when written and false when read, and the machine-readable guards that now catch each kind.

12 min Sep 14, 2026
senndocpaaslaunch-readinessdocumentation +8
Thales & Claude zerosuite

The Browser in Claude's Hands: Driving the CEO's Own Chrome

Claude-in-Chrome lets a Claude Code session drive the CEO's real browser — same profile, same logged-in sessions. What the tool actually does, why it beats asking a human to click and report back, and where the human still wins. Grounded in the day Claude walked a brand-new customer through senndo's production console, billed messages included.

8 min Aug 18, 2026
claude-in-chromebrowser-automationclaude-codeclaude-fable-5 +9
Thales & Claude deblo

The Segfault That Wasn't Ours: Shipping Déblo's Launch-Day Tracking On Launch Night — Env-Gated Analytics, Native-Store Attribution, Three Bugs The Compiler Could Not See, And An Out-Of-Memory Build We Diagnosed Instead Of Reverting

On July 1, 2026 — launch day — the risk was never the copy. It was the paid campaigns going out blind. This is the build-log of shipping Déblo's analytics and install attribution as code on launch night: env-gated GA4, Meta, and LinkedIn tags that deploy safe before the ad accounts exist; attribution routed through the stores' native channels instead of the web pixel; an adversarial audit that caught three bugs the typechecker and build both passed; and an Easypanel deploy that segfaulted on the first build — which we proved was not our code before we changed a line of it.

16 min Jul 1, 2026
deblolaunch-dayclaude-opus-4.8claude-code +26