Back to flin
flin

API Routes: Backend and Frontend in One File

How FLIN's route blocks let you define GET, POST, PUT, DELETE handlers alongside view templates in a single file -- eliminating the frontend/backend divide entirely.

Thales & Claude | March 25, 2026 8 min flin
flinapiroutesfullstack

The modern web stack has a split personality. Your frontend lives in one codebase -- React, Vue, Svelte -- served by one process on one port. Your backend lives in another codebase -- Express, Django, FastAPI -- served by a different process on a different port. Between them sits a CORS configuration, a proxy layer, and a perpetual impedance mismatch between what the frontend expects and what the backend returns.

FLIN erases this boundary. A single .flin file can contain both the API endpoint that serves data and the view template that renders it. There is no CORS because there is no cross-origin. There is no proxy because there is no second server. There is no serialization mismatch because the same type system governs both the data layer and the presentation layer.

The Route Block Syntax

API routes in FLIN are defined with route blocks that specify the HTTP method:

flin// app/api/users.flin

route GET {
    users = User.where(active == true).order(name, "asc")
    users
}

route POST {
    validate {
        name: text @required @minLength(2)
        email: text @required @email
    }

    existing = User.where(email == body.email).first
    if existing != none {
        return error(409, "Email already registered")
    }

    user = User {
        name: body.name,
        email: body.email,
        active: true
    }
    save user

    response {
        status: 201
        body: user
    }
}

The last expression in a route block becomes the response body. If it is a FLIN object or list, it is automatically serialized to JSON. If it is a response literal, the status code and headers are set explicitly.

Route Blocks for Resource Endpoints

The most common pattern in web APIs is CRUD operations on resources. FLIN's route blocks make this pattern concise without sacrificing clarity:

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 {
    validate {
        name: text @minLength(2)
        email: text @email
    }

    user = User.find(params.id)
    if user == none {
        return error(404, "User not found")
    }

    if body.name != none { user.name = body.name }
    if body.email != none { user.email = body.email }
    save user
    user
}

route DELETE {
    user = User.find(params.id)
    if user == none {
        return error(404, "User not found")
    }

    delete user
    { success: true, message: "User deleted" }
}

Three HTTP methods, one file, one resource. The URL parameter id comes from the filename [id].flin. The request body comes from body. The response is the last expression.

Mixing Views and API Routes

The real power of FLIN's route system emerges when views and API routes coexist. Consider a page that both renders HTML and handles form submissions:

flin// app/contact.flin

submitted = false
errorMsg = ""

route POST {
    validate {
        name: text @required
        email: text @required @email
        message: text @required @minLength(10)
    }

    save ContactMessage {
        name: body.name,
        email: body.email,
        message: body.message,
        read: false
    }

    submitted = true

    // No explicit return -- falls through to view rendering
}

<main>
    {if submitted}
        <div class="success">
            <h2>Message sent</h2>
            <p>We will get back to you within 24 hours.</p>
        </div>
    {else}
        <form method="POST">
            {if errorMsg}
                <div class="error">{errorMsg}</div>
            {/if}

            <input type="text" name="name" placeholder="Your name" required>
            <input type="email" name="email" placeholder="Your email" required>
            <textarea name="message" placeholder="Your message" required></textarea>

            <button type="submit">Send Message</button>
        </form>
    {/if}
</main>

A GET request renders the form. A POST request processes the submission, sets submitted = true, and the view section renders the success message. One file. No JavaScript fetch calls. No separate API endpoint. No client-side state management library.

The Implicit Response Contract

FLIN route blocks follow a simple contract for generating responses:

flin// Return an object or list -> JSON with 200 OK
route GET {
    User.all    // -> 200, Content-Type: application/json
}

// Return error() -> JSON error with specified status
route GET {
    return error(404, "Not found")  // -> 404, {"error": "Not found"}
}

// Return response{} -> Custom status, headers, body
route POST {
    response {
        status: 201
        headers: { "Location": "/api/users/" + user.id }
        body: user
    }
}

// Return nothing (no explicit return) -> 204 No Content
route DELETE {
    delete User.find(params.id)
    // implicit 204
}

The error helper error(status, message) is a built-in function that generates a JSON error response with the correct HTTP status code. It is deliberately simple: a status code and a message string. If you need structured error responses, return a response{} literal instead.

Guards on Route Blocks

Guards can be applied at the file level (affecting all routes in the file) or at the individual route level:

flin// app/api/admin/users.flin

guard auth                    // Applies to ALL routes in this file
guard role("admin")           // Must be admin for all routes

route GET {
    User.all
}

route DELETE {
    guard owner(params.id)    // Additional guard for DELETE only

    user = User.find(params.id)
    delete user
    { success: true }
}

File-level guards are evaluated first. If any file-level guard fails, the request is rejected before any route block executes. Route-level guards are evaluated only for their specific method.

How Route Blocks Compile

Under the hood, each route block compiles to a separate bytecode function. The FLIN compiler detects route METHOD { ... } declarations during parsing and generates a dispatch table:

rustpub struct RouteFile {
    /// Compiled view function (for GET requests without route blocks)
    view: Option<CompiledFunction>,

    /// Route block handlers indexed by HTTP method
    routes: HashMap<Method, CompiledFunction>,

    /// File-level guard functions
    guards: Vec<CompiledGuard>,

    /// Top-level variable declarations (shared context)
    init: CompiledFunction,
}

When a request arrives, the runtime first executes the init function to set up shared variables, then evaluates file-level guards, then dispatches to the appropriate route block based on the HTTP method. If no route block matches the method and the file has a view section, a GET request renders the view. Any other unmatched method returns 405 Method Not Allowed automatically.

Query Parameters and Pagination

API routes access query string parameters through the query object:

flin// app/api/products.flin

route GET {
    page = to_int(query.page || "1")
    per_page = to_int(query.per_page || "20")
    category = query.category || ""
    sort = query.sort || "created_at"

    products = Product

    if category != "" {
        products = products.where(category == category)
    }

    products = products
        .order(sort, query.order || "desc")
        .limit(per_page)
        .offset((page - 1) * per_page)

    total = Product.count

    {
        data: products,
        page: page,
        per_page: per_page,
        total: total,
        pages: (total / per_page) + 1
    }
}

Query parameters are always strings. Use to_int(), to_float(), or to_bool() to convert them. Missing parameters return an empty string, not null or undefined. This eliminates the null-check boilerplate that plagues every Express handler.

Error Handling in Route Blocks

Errors in route blocks follow FLIN's general error handling philosophy: explicit, structured, and impossible to ignore.

flin// app/api/orders.flin

route POST {
    validate {
        product_id: int @required
        quantity: int @required @min(1) @max(100)
    }

    product = Product.find(body.product_id)
    if product == none {
        return error(404, "Product not found")
    }

    if product.stock < body.quantity {
        return error(409, "Insufficient stock")
    }

    order = Order {
        product: product,
        quantity: body.quantity,
        total: product.price * body.quantity,
        status: "pending"
    }
    save order

    product.stock = product.stock - body.quantity
    save product

    response {
        status: 201
        body: order
    }
}

If the validate block fails, FLIN automatically returns a 400 Bad Request with a JSON body describing which fields failed validation and why. The route body never executes. This is not middleware you might forget to apply -- it is a language feature that runs before your code.

The Fullstack Advantage

The elimination of the frontend/backend divide has practical consequences that go beyond developer convenience:

Type safety across the boundary. In a traditional stack, the frontend and backend agree on a data shape through documentation, OpenAPI specs, or hope. In FLIN, the entity definition IS the type, and both the API route and the view template use the same entity directly.

No serialization bugs. There is no point where a Date object becomes a string, or a BigInt becomes a truncated Number, or a null becomes undefined. FLIN values flow from the database through the route handler to the view template without transformation.

No network latency for data fetching. A view route can call User.all directly. It does not need to fetch('/api/users') and then parse the JSON response. The data is already in the same process, the same memory space, the same runtime.

This is what "fullstack in one file" actually means. Not a gimmick. Not a demo trick. A fundamental simplification of the web application architecture that eliminates entire categories of bugs, boilerplate, and complexity.

In the next article, we look at how FLIN automatically parses JSON, form data, and multipart bodies -- turning raw HTTP bytes into typed FLIN values before your code even runs.


This is Part 98 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 - [98] API Routes: Backend and Frontend in One File (you are here) - [99] Auto JSON and Form Body Parsing

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles