Back to flin
flin

Enrutamiento basado en archivos en FLIN

Cómo la convención del directorio app/ de FLIN elimina la configuración de rutas por completo -- tu sistema de archivos ES tu estructura de URLs, con segmentos dinámicos, rutas catch-all y herencia de middleware.

Juste A. Gnimavo (Thales) & Claude | March 26, 2026 9 min flin
EN/ FR/ ES
flinrust

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.

La convención de directorios

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.

Cómo el router construye la tabla de rutas

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.

Segmentos dinámicos

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)

Rutas catch-all

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.

Prioridad de rutas

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.

Rutas de vista vs. rutas API

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.

Herencia de middleware

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.

Por qué gana el enrutamiento basado en archivos

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.

En el próximo artículo, 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.


Esta es la Parte 97 de la serie "Cómo construimos FLIN", que documenta cómo un CEO en Abiyán y un CTO de IA diseñaron y construyeron un lenguaje de programación desde cero.

Navegación de la serie: - [96] El servidor HTTP embebido de FLIN - [97] Enrutamiento basado en archivos en FLIN (estás aquí) - [98] Rutas API: backend y frontend en un solo archivo - [99] Análisis automático de JSON y cuerpos de formulario

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles

Thales & Claude deblo

El segfault que no era nuestro: cómo lanzamos el tracking del día de lanzamiento de Déblo en la noche del despliegue — analítica condicionada por entorno, atribución nativa de las tiendas, tres bugs que el compilador no podía ver y un build sin memoria que diagnosticamos en lugar de revertir

El 1 de julio de 2026 — el día del lanzamiento — el riesgo nunca fue el texto. Era que las campañas de pago salieran a ciegas. Este es el build-log de cómo desplegamos la analítica y la atribución de instalaciones de Déblo como código en la noche del lanzamiento: etiquetas GA4, Meta y LinkedIn condicionadas por entorno que se despliegan sin riesgo antes de que existan las cuentas publicitarias; atribución enrutada por los canales nativos de las tiendas en lugar del pixel web; una auditoría adversarial que atrapó tres bugs que tanto el typechecker como el build dieron por buenos; y un despliegue en Easypanel que hizo segfault en el primer build — que demostramos que no era nuestro código antes de tocar una sola línea.

18 min Jul 1, 2026
deblolaunch-dayclaude-opus-4.8claude-code +26
Thales & Claude thales

Trece agentes, cuarenta y tres minutos: la primera sesión Workflow de Claude Fable 5, y lo que un script de orquestación determinista cambia en los builds multiagente

Un prompt, trece agentes, cuarenta y tres minutos: la primera sesión de producción con Claude Fable 5 y la herramienta Workflow de Claude Code entregó un sitio web de producción completo de siete páginas más un endpoint backend de captura de leads, en un solo commit. La bitácora: el script de orquestación determinista, el patrón de inyección de contrato entre fases, la economía por agente del fan-out paralelo, y el suspenso del límite de sesión que el diario de reanudación convirtió en un no-evento.

23 min Jun 12, 2026
claude-fable-5claude-codeworkflow-toolmulti-agent +10
Thales & Claude casp

La puerta detectó su propia deriva: un día dentro de CASP con Claude Fable 5

Le entregamos al modelo Claude más autónomo hasta la fecha las llaves de CASP — la CLI open source que mantiene honestos a los agentes de código IA frente a git — con la autoridad de rechazar nuestra propia roadmap. Rechazó cinco cosas, encontró dos bugs reales en el validador al hacerle dogfooding, los corrigió bajo una puerta de dos auditores, y dejó casp check completamente en verde sobre su propio repositorio por primera vez. CASP 0.3.0 es el resultado.

15 min Jun 10, 2026
caspzerosuiteworkflowai-cto +9