Routing
Routing is the layer that maps an incoming HTTP request or WebSocket upgrade to the Dart code that should handle it. In Flint, routing is built from Flint, Router, RouteBuilder, RouteGroup, Context, and Request.
Before coding routes in a developer app, inspect:
lib/main.dartforFlint(...), global middleware,app.routes(...),app.mount(...),app.static(...), andapp.listen(...).lib/routes/forRouteGroupclasses.lib/controllers/for controller actions.lib/middlewares/for auth, role, tenant, CORS, or other route guards.lib/models/when a route reads or writes database records.- Authentication before adding auth, login, session, token, send OTP, verify OTP, or resend OTP routes.
- Middleware before adding route-specific or group middleware.
- Validation before validating route input.
- WebSockets before adding WebSocket routes.
Framework source to inspect when behavior is unclear:
lib/src/app.dartlib/src/context.dartlib/src/request.dartlib/src/response.dartlib/src/controller.dartlib/src/routing/router.dartlib/src/routing/route_builder.dartlib/src/routing/route_group.dartlib/src/websocket/ws_router.dart
The Mental Model
New Flint route handlers should receive Context:
app.get('/health', (Context ctx) {
return ctx.res?.json({'ok': true});
});
Context is the single object that unifies HTTP routes, WebSocket routes, middleware, and controllers.
ctx.reqis theRequest.ctx.resis theResponse?; it exists for HTTP routes.ctx.socketis theFlintWebSocket?; it exists for WebSocket routes.ctx.isHttpis true whenctx.resexists.ctx.isWebSocketis true whenctx.socketexists.ctx.write<T>(value)stores typed data for later middleware, routes, or controllers.ctx.read<T>()reads typed data from the context.
Use ctx.req for incoming data. Use ctx.res for outgoing HTTP responses. Use ctx.socket for WebSocket events.
The older two-argument HTTP handler style is still adapted for compatibility, but new and advanced apps should teach and write Context routes.
Route Methods On Flint
Flint exposes these HTTP route methods:
app.get(path, handler);
app.post(path, handler);
app.put(path, handler);
app.patch(path, handler);
app.delete(path, handler);
app.query(path, handler);
app.route(method, path, handler);
Each route method returns a RouteBuilder, so middleware can be attached with .useMiddleware(...).
app
.get('/profile', (Context ctx) async {
final user = await ctx.req.user;
return ctx.res?.json({'user': user});
})
.useMiddleware(AuthMiddleware());
get
Use GET for reads that do not change server state.
app.get('/courses', (Context ctx) async {
final courses = await Course().orderBy('created_at', desc: true).get();
return ctx.res?.json({'data': courses});
});
post
Use POST for creation, login, OTP sends, webhook receives, and other actions that submit a body.
app.post('/auth/send-otp', (Context ctx) async {
final data = await ctx.req.validate({
'email': 'required|email',
});
final email = data['email'].toString().trim().toLowerCase();
final otp = await Auth.generateNumericVerificationCode(email);
await SendAuthOtpAction().call(email: email, otp: otp);
return ctx.res?.json({'message': 'OTP sent'});
});
SendAuthOtpAction is application code. See Authentication and Mail before wiring OTP routes.
put
Use PUT when replacing or updating a resource by id.
app.put('/courses/:id', (Context ctx) async {
final input = await ctx.req.validate({
'id': 'required|string',
'title': 'required|string|min:3',
});
final course = await Course().update(
id: input['id'],
data: {'title': input['title']},
);
return ctx.res?.json({'data': course});
});
patch
Use PATCH for partial updates.
app.patch('/courses/:id/status', (Context ctx) async {
final input = await ctx.req.validate({
'id': 'required|string',
'status': 'required|in:draft,published',
});
final course = await Course().update(id: input['id'], data: {
'status': input['status'],
});
return ctx.res?.json({'data': course});
});
delete
Use DELETE for removing a resource.
app.delete('/courses/:id', (Context ctx) async {
await Course().delete(ctx.req.param('id'));
return ctx.res?.status(204).send('');
});
query
QUERY is supported by Flint for safe, idempotent reads that need a request body. Use it for complex searches and filters that are too large or structured for URL query parameters.
app.query('/courses/search', (Context ctx) async {
final body = await ctx.req.json();
final courses = await CourseSearch().call(body);
return ctx.res?.json({'data': courses});
});
QUERY is part of Flint routing and response handling, but it is not a standard OpenAPI operation key. The Swagger generator documents it with Flint-specific metadata.
route
Use route(...) for custom or less common methods such as OPTIONS, HEAD, or provider-specific extension methods.
app.route('OPTIONS', '/courses', (Context ctx) {
final res = ctx.res;
if (res == null) return null;
res.raw.headers.set('Allow', 'GET, POST, QUERY, OPTIONS');
return res.status(204).send('');
});
websocket
Use app.websocket(...) for realtime features. WebSocket handlers should also receive Context.
app.websocket('/chat', (Context ctx) {
final socket = ctx.socket;
if (socket == null) return;
socket.on('message', (data) {
socket.emitToAll('message', data);
});
});
WebSocket route middleware is passed with the middlewares argument:
app.websocket(
'/chat',
(Context ctx) {
ctx.socket?.emit('ready', {'ok': true});
},
middlewares: [ChatSocketAuthMiddleware()],
);
Route Paths
Route paths should start with /. RouteBuilder.normalizedPath adds the leading slash when missing and removes a trailing slash except for /.
app.get('/courses', handler);
app.get('/courses/:id', handler);
app.get('/files/*', handler);
Incoming paths are normalized by Flint.normalizePath() so duplicate slashes are collapsed and a trailing slash is removed except for the root path.
Route Parameters
Use :name to capture a path segment.
app.get('/courses/:id', (Context ctx) {
final id = ctx.req.params['id'];
return ctx.res?.json({'id': id});
});
The shortcut req.param(name) reads from req.params:
final id = ctx.req.param('id');
Parameter regex segments are supported:
app.get('/users/:id(\\d+)', (Context ctx) {
return ctx.res?.json({'id': ctx.req.param('id')});
});
Wildcards only match route paths ending in /*.
app.get('/assets/*', (Context ctx) {
return ctx.res?.send('asset route');
});
Route Matching
Router.match() resolves routes in this order:
- Exact and parameter matches for the request method.
- Wildcard routes ending in
/*. HEADfallback to a matchingGETroute.- Automatic
OPTIONSresponse when the path exists for another method. - Automatic
405 Method Not Allowedwhen the path exists but the method is not allowed. - The app-level
404 Not Foundhandler when no route matches.
The automatic Allow header includes HEAD when GET exists and includes OPTIONS when any method exists for that path.
Returning From Handlers
A route can write to ctx.res directly:
app.get('/plain', (Context ctx) {
return ctx.res?.send('hello');
});
A route can also return data and let Flint serialize it:
app.get('/health', (Context ctx) {
return {'ok': true};
});
Returned Response values are already handled. Returned Model instances and objects with toMap() or toJson() are sent with res.json(...). Other values are sent with res.respond(...), which infers JSON, HTML, or plain text.
If you use ctx.res?.json(...), ctx.res?.send(...), or another response method that closes the response, return it and do not write again later.
Route Groups
Use RouteGroup to keep related routes together.
class CourseRoutes extends RouteGroup {
@override
String get prefix => '/courses';
@override
String get tag => 'Courses';
@override
void register(Flint app) {
app.get('/', (Context ctx) => Course().all());
app.get('/:id', (Context ctx) => Course().find(ctx.req.param('id')));
}
}
Register the group in lib/main.dart:
app.routes(CourseRoutes());
RouteGroup provides:
abstract class RouteGroup {
String get prefix => '';
String get tag => '';
List<Middleware> get middlewares => const [];
void register(Flint app);
}
prefix is added to every route inside the group. tag is useful for documentation and tooling. middlewares applies to every route in the group.
Nested Route Groups
app.routes(group, children: [...]) registers a parent group and child groups under the same mounted tree.
app.routes(
ApiRoutes(),
children: [
CourseRoutes(),
UserRoutes(),
],
);
If ApiRoutes.prefix is /api, CourseRoutes.prefix is /courses, and UserRoutes.prefix is /users, the final route paths become:
/api/courses
/api/users
Child groups inherit the parent prefix and middleware through mounting.
Mounting
app.mount(prefix, callback, middlewares: [...]) creates a sub-Flint, lets the callback register routes on it, then copies those routes into the parent app under the prefix.
app.mount('/api', (api) {
api.get('/health', (Context ctx) => {'ok': true});
}, middlewares: [
ApiMiddleware(),
]);
Use RouteGroup for normal app features. Use mount(...) when you need to compose a small sub-application or register a package/module under a prefix.
Route Middleware
Route-specific middleware uses .useMiddleware(...).
app
.post('/courses', (Context ctx) async {
final data = await ctx.req.validate({'title': 'required|string'});
return Course().create(data);
})
.useMiddleware(AuthMiddleware());
Group middleware is declared on the RouteGroup:
class AdminRoutes extends RouteGroup {
@override
String get prefix => '/admin';
@override
List<Middleware> get middlewares => [
AuthMiddleware(),
RoleMiddleware('admin'),
];
@override
void register(Flint app) {
app.get('/dashboard', (Context ctx) => {'ok': true});
}
}
Global middleware is registered in lib/main.dart:
app.use(CorsMiddleware());
app.use(LoggerMiddleware());
The important practical rule: app.use(...) is for global middleware. .useMiddleware(...) is for a specific route. There is no .use(...) alias on RouteBuilder.
Middleware lists behave like a wrapper stack. The last middleware in a list runs first on the way in and finishes last on the way out. See Middleware before changing middleware order. See Logging before changing request logging, log levels, or error log behavior.
Controllers
For feature code, prefer request-scoped controllers that extend Controller. Register them through app.controller(...).
class CourseController extends Controller {
Future<Response> index() async {
final courses = await Course().all();
return res.json({'data': courses});
}
Future<Response> show() async {
final course = await Course().find(req.param('id'));
if (course == null) {
return res.status(404).json({'message': 'Course not found'});
}
return res.json({'data': course});
}
}
Route group:
class CourseRoutes extends RouteGroup {
@override
String get prefix => '/courses';
@override
void register(Flint app) {
final courses = app.controller(CourseController.new);
courses.get('/', (controller) => controller.index());
courses.get('/:id', (controller) => controller.show());
}
}
app.controller(CourseController.new) creates a route builder that constructs a fresh controller per request, binds the current Context, runs the action, and unbinds the controller afterwards.
Inside a controller:
contextis the boundContext.reqiscontext.req.resiscontext.res, and throws if the action is running in a WebSocket context.socketiscontext.socket, and throws if the action is running in an HTTP context.read<T>()andwrite<T>(value)proxy tocontext.read<T>()andcontext.write<T>().
If a controller action is not registered through app.controller(...), wrap it with controller(...) or useController(...) so Flint binds the current Context.
app.get('/profile', controller(ProfileController.new, (c) => c.show()));
app.get(
'/profile/settings',
useController(ProfileController.new, (c) => c.settings()),
);
Request Reference
Request is Flint's wrapper around Dart's HttpRequest. You normally access it as ctx.req or as req inside a Controller.
app.post('/courses/:id', (Context ctx) async {
final req = ctx.req;
final id = req.param('id');
final data = await req.json();
return ctx.res?.json({'id': id, 'data': data});
});
Raw Request
final raw = req.raw;
raw is the original HttpRequest from dart:io. Use it only when Flint's helpers do not expose what you need.
Basic Properties
req.method;
req.path;
req.uri;
req.headers;
req.query;
req.ipAddress;
req.clientIpAddress;
methodis the incoming HTTP method, such asGET,POST,QUERY, orDELETE.pathis the URL path without the query string.uriis the fullUri.headersreturns request headers as aMap<String, String>.queryreturns URL query parameters as aMap<String, String>.ipAddressuses the direct socket address.clientIpAddresschecks common proxy headers such as Cloudflare,X-Forwarded-For, andX-Real-IPbefore falling back toipAddress.
When exact header behavior matters, inspect req.raw.headers because header names can vary by client, proxy, and server.
Route Params And Query Params
final id = req.params['id'];
final sameId = req.param('id');
final page = req.query['page'];
final samePage = req.queryParam('page');
final routeOrQuery = req['id'];
paramscontains route params captured from path segments such as:id.param(key)reads one route param.querycontains URL query parameters.queryParam(key)reads one query param.operator [](key)checksparamsfirst, thenquery.
req['name'] does not read JSON or form body fields. Use await req.input(...) or await req.allInput() when you want normalized request input.
Normalized Input
final value = await req.input('email');
final input = await req.allInput();
input(key) returns one value from allInput().
allInput() merges request data in this precedence order:
query < body/form fields < uploaded files < route params
That means a route param wins over a body field with the same key, and an uploaded file wins over a normal form field with the same key.
Use allInput() in validators, filters, and controller actions where the source can be query string, JSON, form data, file upload, or route param.
Request-Scoped Storage
req.set('tenant_id', 'school_123');
final tenantId = req.get('tenant_id');
req.set(...) and req.get(...) are request-local storage helpers. They are not database methods and req.get(...) is not an HTTP GET helper.
Prefer ctx.write<T>(value) and ctx.read<T>() when middleware needs to pass typed objects to later middleware, routes, or controllers.
Cookies
final cookies = req.cookies;
final sessionId = req.sessionId;
cookies parses the Cookie header into a Map<String, String>. sessionId reads the FLINTSESSID cookie.
Use response cookie helpers such as res.setCookie(...) and res.clearCookie(...) when writing cookies.
Auth And JWT
final bearer = req.bearerToken;
final token = req.authToken;
final jwt = req.jwt;
final user = await req.user;
final authenticated = req.isAuthenticated;
final requiredUser = req.requireUser();
bearerTokenreadsAuthorization: Bearer <token>.authTokenprefers the bearer token, then checks auth cookies.jwtcreates aFlintJwthelper usingJWT_SECRET.usertries to verify the auth token, then falls back to session data.isAuthenticatedchecks whetheruserhas already been cached on the request.requireUser()returns the cached user or throwsAuthException.
Important: call await req.user before relying on req.isAuthenticated or req.requireUser(). isAuthenticated is a cache check; it does not perform the lookup by itself.
Middleware can load and store the user once:
class AuthMiddleware extends Middleware {
@override
Handler handle(Handler next) {
return (Context ctx) async {
final res = ctx.res;
if (res == null) return await next(ctx);
final user = await ctx.req.user;
if (user == null) {
return res.status(401).json({'message': 'Unauthorized'});
}
ctx.write<Map<String, dynamic>>(user);
return await next(ctx);
};
}
}
Then a controller can read it:
final user = read<Map<String, dynamic>>();
Sessions
final current = await req.session;
final newSessionId = await req.startSession({'id': user.id});
final rotatedSessionId = await req.updateSession({'role': 'admin'});
await req.destroySession();
sessionreads current session data throughSessionManager.startSession(data, ttl: ...)creates a session, sets the session cookie, and cachesuserin request storage.updateSession(updates, ttl: ...)merges updates, destroys the old session, creates a new session, and caches the merged data.destroySession()destroys the current session and removes cacheduser.
Session helpers need access to the underlying HTTP response because they write cookies. In normal routes and controllers this is already available.
Read Sessions And Cookies for session drivers, cookie options, flash messages, browser auth session storage, and logout guidance.
Body Parsing
final text = await req.body();
final bytes = await req.rawBody();
final json = await req.json();
final form = await req.form();
body()returns the raw request body decoded as UTF-8 text.rawBody()returns the exact request body bytes and caches them.json()parsesapplication/jsonand expects a JSON object.form()parsesapplication/x-www-form-urlencodedand multipart form fields.
Body parsing is cached. Calling rawBody(), json(), form(), allInput(), or validation helpers does not permanently consume the body for later Flint parsers.
json() returns an empty map for an empty JSON body. It throws FormatException when the body is not a JSON object.
File Uploads
Multipart uploads are represented by UploadedFile.
final hasAvatar = await req.hasFile('avatar');
final avatar = await req.file('avatar');
final gallery = await req.files('gallery');
final allFiles = await req.allFiles();
File helpers:
hasFile(fieldName)checks whether a single file field exists.hasFiles(fieldName)checks exact and array-style file fields such asgallery[]orgallery[0].file(fieldName)returns oneUploadedFile?.files(fieldName)returns uploaded files for exact and array-style field names.allFiles()returns every uploaded file by field name.
UploadedFile exposes:
upload.fieldName;
upload.filename;
upload.contentType;
upload.size;
upload.extension;
upload.uploadedAt;
upload.content;
await upload.saveTo('public/uploads/avatar.png');
Convenience storage helpers:
final path = await req.storeFile(
'avatar',
directory: 'public/uploads/avatars',
filename: 'user-${req.param('id')}.png',
);
final paths = await req.storeFiles(
'gallery',
directory: 'public/uploads/gallery',
);
storeFile(...)saves one uploaded file and returns the saved path.storeFiles(...)saves multiple uploaded files and returns their saved paths.
Always validate file type, size, and authorization before trusting uploads.
Validation
final data = await req.validate({
'email': 'required|email',
'password': 'required|string|min:8|confirmed',
});
validate(...) reads normalized input from query, body, form fields, uploaded files, and route params. It returns the validated data or throws a validation exception that ExceptionMiddleware can turn into a JSON response.
Custom messages:
final data = await req.validate(
{'email': 'required|email'},
messages: {
'email.required': 'Email is required.',
},
);
validateForm(...) still exists but is deprecated. Use validate(...).
Request Examples In Routes
Create with JSON:
app.post('/courses', (Context ctx) async {
final data = await ctx.req.validate({
'title': 'required|string|min:3',
'status': 'in:draft,published',
});
final course = await Course().create(data);
return ctx.res?.status(201).json({'data': course});
});
Search with query string:
app.get('/courses', (Context ctx) async {
final page = int.tryParse(ctx.req.queryParam('page') ?? '1') ?? 1;
final status = ctx.req.queryParam('status');
final query = Course().orderBy('created_at', desc: true);
if (status != null) {
query.where('status', status);
}
final courses = await query.paginate(page);
return ctx.res?.json({'data': courses});
});
Upload a file:
app.post('/profile/avatar', (Context ctx) async {
final user = await ctx.req.user;
if (user == null) {
return ctx.res?.status(401).json({'message': 'Unauthorized'});
}
if (!await ctx.req.hasFile('avatar')) {
return ctx.res?.status(422).json({'message': 'Avatar is required'});
}
final path = await ctx.req.storeFile(
'avatar',
directory: 'public/uploads/avatars',
);
return ctx.res?.json({'path': path});
});
Read a signed webhook raw body:
app.post('/webhooks/provider', (Context ctx) async {
final signature = ctx.req.headers['x-provider-signature'];
final rawBody = await ctx.req.rawBody();
await VerifyWebhookSignatureAction().call(signature, rawBody);
await ProcessWebhookAction().call(rawBody);
return ctx.res?.json({'received': true});
});
Static Files
For normal public assets, Flint installs StaticFileMiddleware by default and serves from public.
You can also register a static route manually:
app.static('/assets', 'public/assets');
Static files are route-adjacent, but most app features should use normal routes, controllers, and middleware instead of custom static handlers.
Common Mistakes
- Do not teach new code to use legacy two-argument HTTP handlers when
Context ctxworks. - Do not call
req.get('id')expecting query data;req.get(...)reads request-scoped storage. - Do not use
req['field']for JSON or form fields; useawait req.input('field'). - Do not rely on
req.isAuthenticatedbeforeawait req.user. - Do not attach route middleware with
.use(...); use.useMiddleware(...). - Do not create one route file with many unrelated classes. Keep each
RouteGroup, controller, middleware, service, action, and reusable frontend component in its own file. - Do not put business workflows inside route closures. For real features, route to a controller, and extract workflows to service/action classes.
- Do not use
QUERYas if every external tool understands it; document it clearly for clients and API docs. - Do not read a WebSocket response from
ctx.res; WebSocket routes usectx.socket. - Do not create a new
RequestorResponseinside route code; usectx.reqandctx.res.
One language powering Full-Stack Web, Cross-Platform Clients, Native AI, and Connected Robotics.