CrowCpp Route Handlers: Lambda Captures and Closures

When you write a route in Crow, the handler you pass is a C++ lambda. Not a special framework callback — a real C++ lambda with all the language features that implies: captures, closures, factories, and composition. Most Crow tutorials stop at []() { return "Hello"; }. This one does not.

The route handler is a lambda — literally

Consider a basic Crow app:

#include "crow.h"

int main() {
    crow::SimpleApp app;

    // []() { ... } is a standard C++ lambda
    CROW_ROUTE(app, "/hello")([]() {
        return "Hello, World!";
    });

    app.port(8080).run();
}

The double parenthesis after CROW_ROUTE calls operator() on the route object, passing your lambda as the handler. The [] is the capture list. This is standard C++ lambda syntax — no macros, no magic beyond route registration itself. That means everything you know about lambdas applies here.

Capture by reference: shared mutable state

Declare a variable in main, capture it by reference across multiple routes. No globals, no singletons. Here two routes share an atomic hit counter:

#include "crow.h"
#include <atomic>

int main() {
    crow::SimpleApp app;
    std::atomic<int> hits{0};

    CROW_ROUTE(app, "/ping")([&hits]() {
        return "pong #" + std::to_string(++hits);
    });

    CROW_ROUTE(app, "/stats")([&hits]() {
        crow::json::wvalue res;
        res["hits"] = hits.load();
        return res;
    });

    app.port(8080).run();
}

Both lambdas reference the same hits object. std::atomic<int> makes the increment thread-safe. Lifetime is safe because hits is declared above app in the same scope — it outlives the routes.

Thread safety: mutex as captured state

For non-atomic shared data, capture a mutex alongside your container. Here is a simple in-memory key-value store shared across two routes:

#include "crow.h"
#include <unordered_map>
#include <mutex>
#include <string>

int main() {
    crow::SimpleApp app;

    std::unordered_map<std::string, std::string> kv;
    std::mutex mtx;

    CROW_ROUTE(app, "/set/<string>/<string>")
    ([&kv, &mtx](const std::string& k, const std::string& v) {
        std::lock_guard<std::mutex> lock(mtx);
        kv[k] = v;
        return crow::response(200, "stored");
    });

    CROW_ROUTE(app, "/get/<string>")
    ([&kv, &mtx](const std::string& k) {
        std::lock_guard<std::mutex> lock(mtx);
        auto it = kv.find(k);
        if (it == kv.end()) return crow::response(404, "not found");
        return crow::response(200, it->second);
    });

    app.port(8080).run();
}

The mutex and map travel together through capture lists. Any new route that needs the store just captures both. No changes to existing handlers.

Capture by value: snapshot configuration

Capture by reference gives you the current value at call time. Capture by value gives you a snapshot at registration time. Use this for configuration that should be fixed when the server starts:

#include "crow.h"
#include <string>

int main() {
    crow::SimpleApp app;

    const std::string version = "1.0.0";
    const std::string build_env = "production";

    // Changes to version/build_env after this point do NOT affect the handler
    CROW_ROUTE(app, "/status")([version, build_env]() {
        crow::json::wvalue res;
        res["version"] = version;
        res["env"] = build_env;
        return res;
    });

    app.port(8080).run();
}

The handler owns its own copies of version and build_env. Values are baked in at registration time — the lambda equivalent of immutable configuration.

Route factories: lambdas that return lambdas

When multiple routes share the same structure but different data, a factory function eliminates duplication. The factory is a plain function that returns a pre-configured lambda:

#include "crow.h"
#include <string>

// Returns a handler pre-loaded with a resource name
auto make_rest_handler(const std::string& resource) {
    return [resource](const crow::request& req) {
        crow::json::wvalue res;
        res["resource"] = resource;
        res["method"] = req.method_name();
        return crow::response(res);
    };
}

int main() {
    crow::SimpleApp app;

    CROW_ROUTE(app, "/users").methods("GET"_method)(make_rest_handler("users"));
    CROW_ROUTE(app, "/posts").methods("GET"_method)(make_rest_handler("posts"));
    CROW_ROUTE(app, "/tags").methods("GET"_method)(make_rest_handler("tags"));

    app.port(8080).run();
}

Each call to make_rest_handler produces a distinct closure with its own resource value. Three routes, one implementation. Adding /comments is a one-liner.

Middleware via lambda composition

A middleware function takes a handler and returns a new handler — a lambda wrapping a lambda. Define the handler type alias once, then write middleware as pure functions you can stack in any order.

Two middleware wrappers — token auth and request logging:

#include "crow.h"
#include <functional>
#include <string>

using Handler = std::function<crow::response(const crow::request&)>;

Handler require_token(const std::string& secret, Handler next) {
    return [secret, next](const crow::request& req) -> crow::response {
        auto auth = req.get_header_value("Authorization");
        if (auth != "Bearer " + secret)
            return crow::response(401, "Unauthorized");
        return next(req);
    };
}

Handler with_logging(const std::string& label, Handler next) {
    return [label, next](const crow::request& req) -> crow::response {
        CROW_LOG_INFO << label << " " << req.method_name();
        auto res = next(req);
        CROW_LOG_INFO << label << " -> " << res.code;
        return res;
    };
}

Now compose them on a route. Reading inside-out: data_handler is the core logic, require_token wraps it with auth, with_logging wraps the auth-protected handler with logging:

int main() {
    crow::SimpleApp app;
    const std::string api_key = "super-secret-key-123";

    auto data_handler = [](const crow::request&) {
        crow::json::wvalue res;
        res["data"] = "top secret";
        return crow::response(res);
    };

    // Compose: logging -> auth -> handler
    CROW_ROUTE(app, "/admin/data")(
        with_logging("/admin/data",
            require_token(api_key, data_handler)
        )
    );

    app.port(8080).run();
}

Each middleware is independently reusable. Apply require_token to ten routes without duplicating the auth logic. Apply with_logging selectively. Mix and match.

Mutable captures: per-handler local state

Lambda captures are const by default. The mutable keyword removes that restriction. Combined with C++14 init-captures, each route gets its own independent state with no shared data between routes:

#include "crow.h"
#include <string>

int main() {
    crow::SimpleApp app;

    // Each route has its own independent count via init-capture
    CROW_ROUTE(app, "/a")([count = 0]() mutable {
        return "A called " + std::to_string(++count) + " times";
    });

    CROW_ROUTE(app, "/b")([count = 0]() mutable {
        return "B called " + std::to_string(++count) + " times";
    });

    // Hitting /a ten times does not affect /b's counter
    app.port(8080).run();
}

Warning: mutable captures are not thread-safe. If Crow runs multithreaded and two requests hit the same route concurrently, there is a data race on count. Use std::atomic or a mutex if you need both mutability and thread safety.

Summary

  • Capture by reference [&var] — share state across routes; lifetimes must be safe
  • Capture by value [var] — snapshot config at registration; changes don’t propagate
  • Route factories — functions returning pre-configured lambdas; eliminate route duplication
  • Lambda composition — wrap handlers with auth, logging, rate-limiting; each layer reusable
  • Mutable init-captures — per-route local state; no globals, no sharing between routes

None of this is Crow-specific. It is standard C++ applied to web server route registration. Once you see route handlers as lambdas, the full expressiveness of C++ closures is available in your server code.

Leave a Comment

Your email address will not be published. Required fields are marked *