dart-frog
Dart Frog Framework Guide
Applies to: Dart Frog 1.x, Dart 3.x, REST APIs, Full-Stack Dart, Serverless Functions
Overview
Dart Frog is a fast, minimalistic backend framework for Dart built on top of Shelf. It provides file-based routing (similar to Next.js), built-in middleware support, dependency injection via providers, hot reload during development, and easy deployment with Docker. Dart Frog pairs naturally with Flutter for full-stack Dart applications.
Key Features
- File-based routing: route structure mirrors the filesystem
- Middleware cascades:
_middleware.dartfiles apply to all routes in their directory subtree - Dependency injection:
provider<T>()andcontext.read<T>()for clean service access - Hot reload:
dart_frog devwatches for changes automatically - Production builds: compiles to a self-contained server binary
- Docker-ready: ships with a Dockerfile scaffold
Guardrails
Project Organization
- Place all route handlers under
routes/ - Place business logic, models, repositories, and services under
lib/src/ - Export a barrel file at
lib/<project_name>.dart - Keep route files thin: delegate to services, never embed business logic in handlers
- Mirror test structure under
test/routes/to matchroutes/layout
Code Style
- Run
dart format .before every commit - Run
dart analyzeand fix all warnings before committing - Use
very_good_analysislint rules (orlintspackage at minimum) - Exclude generated files (
*.g.dart,*.freezed.dart) from analysis
Error Handling
- Define custom exception classes extending a base
AppException - Use a global error-handler middleware to catch all exceptions and return JSON
- Never expose stack traces in production responses
- Always return structured JSON error bodies:
{"error": "<message>"} - Use
dart:ioHttpStatusconstants instead of magic status code numbers
Security
- Validate all request body fields before processing
- Use parameterized queries for all database access
- Never hardcode secrets; read from environment variables via a
Configclass - Set CORS allowed origins to specific domains in production (never
*) - Verify JWT signatures with a secret loaded from config, not inline strings
Project Structure
myapp/
routes/
_middleware.dart # Global middleware (logging, CORS, error handler, DI)
index.dart # GET /
health.dart # GET /health
api/
_middleware.dart # JSON content-type header
v1/
_middleware.dart # Auth provider
users/
_middleware.dart # Require authentication
index.dart # GET|POST /api/v1/users
[id].dart # GET|PUT|DELETE /api/v1/users/:id
posts/
index.dart
[id].dart
auth/
login.dart # POST /api/v1/auth/login
register.dart # POST /api/v1/auth/register
ws.dart # WebSocket endpoint
lib/
myapp.dart # Barrel export
src/
models/
user.dart
repositories/
user_repository.dart
services/
user_service.dart
middleware/
auth_provider.dart
exceptions.dart
test/
routes/
api/v1/users/
index_test.dart
pubspec.yaml
Dockerfile
File-Based Routing
Route-to-File Mapping
| URL Path | File |
|---|---|
/ |
routes/index.dart |
/health |
routes/health.dart |
/api/v1/users |
routes/api/v1/users/index.dart |
/api/v1/users/:id |
routes/api/v1/users/[id].dart |
/api/v1/users/:uid/posts/:pid |
routes/api/v1/users/[uid]/posts/[pid].dart |
Handler Signature
Every route file must export a top-level onRequest function:
// Synchronous handler (no async work)
Response onRequest(RequestContext context) { ... }
// Async handler
Future<Response> onRequest(RequestContext context) async { ... }
// Dynamic route — parameters are positional String arguments
Future<Response> onRequest(RequestContext context, String id) async { ... }
// Nested dynamic route — one String per segment
Future<Response> onRequest(
RequestContext context,
String userId,
String postId,
) async { ... }
Method Routing
Use a switch on context.request.method to dispatch by HTTP verb:
Future<Response> onRequest(RequestContext context) async {
return switch (context.request.method) {
HttpMethod.get => _handleGet(context),
HttpMethod.post => _handlePost(context),
_ => Future.value(Response(statusCode: HttpStatus.methodNotAllowed)),
};
}
Always return 405 Method Not Allowed for unsupported verbs.
Request Context
Reading the Request
// Query parameters
final page = int.tryParse(
context.request.uri.queryParameters['page'] ?? '1',
) ?? 1;
// JSON body
final body = await context.request.json() as Map<String, dynamic>;
// Headers
final auth = context.request.headers['Authorization'];
Dependency Access
Read any provider-registered dependency:
final userService = context.read<UserService>();
final config = context.read<Config>();
final currentUser = context.read<User?>(); // nullable for optional auth
Building Responses
// JSON with default 200
Response.json({'message': 'OK'});
// JSON with explicit status
Response.json(user.toJson(), statusCode: HttpStatus.created);
// No content
Response(statusCode: HttpStatus.noContent);
// Custom headers
response.copyWith(headers: {...response.headers, 'X-Custom': 'value'});
Middleware
Middleware files are named _middleware.dart and apply to every route in the same directory
and all subdirectories. They execute from outermost to innermost (root first).
Anatomy
Handler middleware(Handler handler) {
return handler
.use(someMiddleware())
.use(provider<SomeType>((context) => SomeType()));
}
Middleware Cascade Order
routes/_middleware.dart -> runs first (global)
routes/api/_middleware.dart -> runs second
routes/api/v1/_middleware.dart -> runs third (e.g., auth provider)
routes/api/v1/users/_middleware.dart -> runs last (e.g., require auth)
routes/api/v1/users/index.dart -> handler
Writing Custom Middleware
A Middleware is a function Handler Function(Handler):
Middleware myMiddleware() {
return (handler) {
return (context) async {
// Before handler
final response = await handler(context);
// After handler
return response;
};
};
}
Common Middleware Patterns
- Request logger: time the request, log method/path/status/duration
- CORS: add
Access-Control-Allow-*headers, handle OPTIONS preflight - Error handler: wrap handler in try/catch, map exceptions to HTTP status codes
- JSON content-type: add
Content-Type: application/jsonto responses - Auth provider: parse JWT from
Authorizationheader, provideUser? - Require auth: check
context.read<User?>(), return 401 if null
Dependency Injection
Dart Frog uses the provider<T>() middleware to register dependencies:
Handler middleware(Handler handler) {
return handler
.use(provider<Config>((_) => Config.fromEnvironment()))
.use(provider<Database>((ctx) => Database(ctx.read<Config>().dbUrl)))
.use(provider<UserRepository>((ctx) =>
UserRepositoryImpl(ctx.read<Database>())))
.use(provider<UserService>((ctx) => UserService(
ctx.read<UserRepository>(),
ctx.read<Config>(),
)));
}
Rules
- Register providers from least-dependent to most-dependent (Config before Database)
- Providers can read previously registered dependencies via
context.read<T>() - Use nullable types (
User?) for optional dependencies like authenticated user - Keep the global middleware as the single source of truth for DI wiring
- Prefer constructor injection in services (accept interfaces, not concrete classes)
Error Handling
Exception Hierarchy
class AppException implements Exception {
final String message;
const AppException(this.message);
}
class ValidationException extends AppException {
final Map<String, List<String>> errors;
const ValidationException(super.message, [this.errors = const {}]);
}
class NotFoundException extends AppException {
const NotFoundException(super.message);
}
class UnauthorizedException extends AppException {
const UnauthorizedException([super.message = 'Unauthorized']);
}
class ForbiddenException extends AppException {
const ForbiddenException([super.message = 'Forbidden']);
}
Global Error Handler Middleware
Middleware errorHandler() {
return (handler) {
return (context) async {
try {
return await handler(context);
} on ValidationException catch (e) {
return Response.json(
{'error': e.message, 'errors': e.errors},
statusCode: HttpStatus.unprocessableEntity,
);
} on NotFoundException catch (e) {
return Response.json(
{'error': e.message}, statusCode: HttpStatus.notFound);
} on UnauthorizedException catch (e) {
return Response.json(
{'error': e.message}, statusCode: HttpStatus.unauthorized);
} on ForbiddenException catch (e) {
return Response.json(
{'error': e.message}, statusCode: HttpStatus.forbidden);
} catch (e, stack) {
print('Unhandled: $e\n$stack');
return Response.json(
{'error': 'Internal server error'},
statusCode: HttpStatus.internalServerError,
);
}
};
};
}
Models
Use freezed + json_serializable for immutable, serializable models:
class User with _$User {
const User._();
const factory User({
required String id,
required String email,
required String name,
(false) bool isAdmin,
(includeToJson: false) String? passwordHash,
required DateTime createdAt,
DateTime? updatedAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Run code generation after model changes:
dart run build_runner build --delete-conflicting-outputs
Commands
# Scaffold a new project
dart_frog create myapp
# Development server with hot reload
dart_frog dev
# Production build
dart_frog build
# Run the compiled server
./build/bin/server
# Generate a new route file
dart_frog new route /api/v1/users
# Generate a new middleware file
dart_frog new middleware auth
# Run tests
dart test
# Code generation (freezed, json_serializable)
dart run build_runner build --delete-conflicting-outputs
# Format and analyze
dart format .
dart analyze
Testing
Route Handler Tests
Use mocktail to mock RequestContext and injected services:
class MockRequestContext extends Mock implements RequestContext {}
class MockUserService extends Mock implements UserService {}
void main() {
late MockRequestContext context;
late MockUserService userService;
setUp(() {
context = MockRequestContext();
userService = MockUserService();
when(() => context.read<UserService>()).thenReturn(userService);
});
test('GET returns users list', () async {
when(() => userService.getAll(page: any(named: 'page'),
limit: any(named: 'limit')))
.thenAnswer((_) async => [mockUser]);
when(() => context.request).thenReturn(
Request.get(Uri.parse('http://localhost/api/v1/users')),
);
final response = await route.onRequest(context);
expect(response.statusCode, equals(HttpStatus.ok));
});
}
Testing Rules
- Mirror route paths in test file paths:
routes/api/v1/users/index.dart->test/routes/api/v1/users/index_test.dart - Mock all injected dependencies via
context.read<T>() - Test each HTTP method separately
- Test error cases (validation, not found, unauthorized)
- Coverage target: >80% for route handlers and services
Advanced Topics
For detailed patterns and examples, see:
- references/patterns.md -- Route patterns, WebSocket, database integration, authentication, deployment