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.