Build your first Flint app
Everything you need to create, run, and ship APIs in Dart.
Introduction
This guide focuses on how to use Flint Dart in real projects: creating an app, routing requests, rendering views, and defining database tables directly in models.
Flint Dart encourages separating logic from routes. Use controllers (via the CLI
flint --make-controller) to keep handlers clean. Controllers are just classes, so when they
grow you can move business logic into services. This keeps apps scalable and production-ready.
Flint Dart is built for real, enterprise applications - not just test projects.
Install
Install the CLI
dart pub global activate flint_dart
Add to an existing project
dart pub add flint_dart
Create & Run
Create a new project
flint create my_app
cd my_app
Run the server
flint run
flint run runs lib/main.dart.
If you are using hot reload for Flint templates, set PORT in your .env.
The framework will use this when app.listen() is called without a port:
PORT=3001
Dart-friendly workflow
Keep your project clean and fast with standard Dart tools. Run these anytime to format and catch issues early:
dart format .
dart analyze
Env Helper
Use the top-level env() helper anywhere to read values from .env.
It will coerce types based on the default value you pass.
final port = env('PORT', 3001); // int
final debug = env('DEBUG', false); // bool
final name = env('APP_NAME', 'Flint');
CLI
Flint ships with a CLI to scaffold files, manage the app, and generate docs. Use the commands below directly from your project root.
Project & Dev
flint create my_app # create a new project (clones the starter)
flint run # run dev server (defaults to 8080)
flint start # alias for run
flint build --linux # build production executable
flint build --windows # build for Windows
flint build --both # build for Linux + Windows
Scaffolding
flint --make-model User
flint --make-controller UserController
flint --make-middleware AuthMiddleware
flint --make-route AuthRoutes
flint --make-resource UserResource
flint --make-mail WelcomeMail
flint --make-isolate ReportJob
flint --make-seeder UserSeeder
flint --make-docker
Database
flint migrate # run table sync/migrations
flint --db-seed # run lib/seeders/seeder.dart
flint --db-export # export full database
flint --db-table-export users # export one table
Seeders
flint --make-seeder BlogPostSeeder
flint --db-seed
Docs & Updates
flint --docs-generate # generate Swagger docs from routes
flint update # update Flint dependencies
flint upgrade # upgrade CLI + project deps
flint version # show CLI version
Command Notes
flint --make-docker- generates Docker files for local and production deploys.flint --docs-generate- parses routes and writesdocs/swagger.json.flint update- updates project dependencies only.flint upgrade- updates the CLI and project dependencies together.
Routing
Define routes in lib/main.dart or a route group.
import 'package:flint_dart/flint_dart.dart';
void main() {
final app = Flint(
withDefaultMiddleware: true,
enableSwaggerDocs: true,
autoConnectDb: false,
);
app.get('/', (Context ctx) async {
return ctx.res?.json({'status': 'ok'});
});
app.get('/users/:id', (Context ctx) async {
final id = ctx.req.params['id'];
return ctx.res?.json({'id': id});
});
app.post('/users', (Context ctx) async {
final data = await ctx.req.json();
return ctx.res?.json({'created': true, 'data': data});
});
app.listen(port: 3000);
}
Attach middleware per route:
app.post('/profile', handler)
.use(AuthMiddleware());
Route Params
Use :param segments. Read them with ctx.req.param(). The raw map is ctx.req.params.
app.get('/users/:id', (Context ctx) async {
final id = ctx.req.param('id');
return ctx.res?.json({'id': id});
});
Query Params
Read query string values from ctx.req.queryParam() or ctx.req.query (e.g. ?page=2&limit=10).
app.get('/users', (Context ctx) async {
final page = ctx.req.queryParam('page') ?? '1';
final limit = ctx.req.queryParam('limit') ?? '10';
return ctx.res?.json({'page': page, 'limit': limit});
});
Unified Context
Route handlers receive a single Context object:
ctx.req (always), ctx.res (HTTP), and ctx.socket (WebSocket).
Request & Response
Common helpers you’ll use in most handlers.
app.post('/users', (Context ctx) async {
final body = await ctx.req.json(); // parse JSON body
final token = ctx.req.bearerToken; // read Authorization token
final ip = ctx.req.ipAddress; // client IP
return ctx.res
.status(201)
.json({'created': true, 'data': body, 'ip': ip, 'token': token});
});
Response Methods
Use the right response method based on what you’re returning.
app.get('/text', (Context ctx) async {
return ctx.res?.send('Plain text');
});
app.get('/json', (Context ctx) async {
return ctx.res?.json({'status': 'ok'});
});
app.get('/auto', (Context ctx) async {
return ctx.res?.respond({'auto': 'json'}); // infers JSON/HTML/text
});
app.get('/page', (Context ctx) async {
return ctx.res?.view('home', data: {'title': 'Flint Docs'});
});
send() is for plain text or custom content types. json() sets JSON headers and
encodes safely. respond() auto-detects the best type. view() renders a template.
For form workflows, you can combine flash messages with redirect-back:
withSuccess(), withError(), and back().
app.post('/settings', (Context ctx) async {
final data = await ctx.req.validate({'name': 'required|string|min:2'});
// ... save settings
return ctx.res
?.withSuccess('Settings updated.')
.back(fallback: '/settings');
});
You can also return values directly from the route handler. Flint will serialize
Map/List as JSON and custom classes that implement
toMap() or toJson().
class UserDto {
final int id;
final String email;
UserDto(this.id, this.email);
Map<String, dynamic> toMap() => {'id': id, 'email': email};
}
app.get('/me', (Context ctx) async {
return UserDto(1, 'ada@example.com'); // auto JSON
});
Request Body
Choose the right body reader based on the incoming content type.
app.post('/raw', (Context ctx) async {
final text = await ctx.req.body(); // raw string body
return ctx.res?.send(text);
});
app.post('/json', (Context ctx) async {
final data = await ctx.req.json(); // Map<String, dynamic>
return ctx.res?.json({'received': data});
});
app.post('/form', (Context ctx) async {
final fields = await ctx.req.form(); // Map<String, String>
return ctx.res?.json({'fields': fields});
});
body() returns a raw string. json() parses JSON into a map.
form() reads application/x-www-form-urlencoded or multipart/form-data.
File Uploads & Storage
Uploads come from multipart/form-data. You can read files, check if they exist, or store them on disk.
ctx.req.file('avatar')— get a single uploaded file.ctx.req.files('photos')— get multiple files for the same field.ctx.req.hasFile('avatar')/ctx.req.hasFiles('photos')— check if files were sent.ctx.req.storeFile(...)/ctx.req.storeFiles(...)— save to disk and return path(s).
app.post('/avatar', (Context ctx) async {
// Access the uploaded file
final upload = await ctx.req.file('avatar');
if (upload == null) {
return ctx.res?.status(400).json({'error': 'No file uploaded'});
}
// Save with a custom name
final path = await ctx.req.storeFile(
'avatar',
directory: 'public/uploads',
filename: 'user-1.png',
);
return ctx.res?.json({'saved_to': path});
});
app.post('/gallery', (Context ctx) async {
if (!await ctx.req.hasFiles('photos')) {
return ctx.res?.status(400).json({'error': 'No photos uploaded'});
}
final paths = await ctx.req.storeFiles('photos', directory: 'public/uploads');
return ctx.res?.json({'saved_to': paths});
});
Tip: you can also save manually using upload.saveTo(path) if you want full control.
Middleware
Middleware runs before your handler. You can register it globally, per HTTP route, per route group, or per WebSocket route.
Default stack: ExceptionMiddleware is enabled when
withDefaultMiddleware: true (default). CookieSessionMiddleware is always
registered to enable cookies and sessions.
Declare middleware
Custom middleware implements Middleware and wraps the next Handler.
class AuthMiddleware extends Middleware {
@override
Handler handle(Handler next) {
return (Context ctx) async {
final token = ctx.req.headers['authorization'];
if (token == null || token.isEmpty) {
// For HTTP requests, return a response.
if (ctx.res != null) {
return ctx.res!.status(401).json({'error': 'Unauthorized'});
}
// For WebSocket contexts without HTTP response, just stop the chain.
return null;
}
return await next(ctx);
};
}
}
Global middleware
import 'package:flint_dart/flint_dart.dart';
import 'middlewares/auth_middleware.dart';
void main() {
final app = Flint();
app.use(AuthMiddleware());
app.get('/profile', (Context ctx) async {
return ctx.res?.json({'ok': true});
});
app.listen(port: 3000);
}
HTTP route middleware
app.get('/admin', (Context ctx) async {
return ctx.res?.send('Admin');
}).use(AuthMiddleware());
Route group middleware
class AdminRoutes extends RouteGroup {
@override
String get prefix => '/admin';
@override
List get middlewares => [AuthMiddleware()];
@override
void register(Flint app) {
app.get('/users', (Context ctx) async => ctx.res?.json([]));
}
}
WebSocket route middleware
Use the middlewares parameter in app.websocket().
app.websocket(
'/chat',
(Context ctx) {
final socket = ctx.socket;
if (socket == null) return;
socket.on('ping', (_) => socket.emit('pong', {'ok': true}));
},
middlewares: [AuthMiddleware()],
);
Route middlewares run around the connected WebSocket handler.
Use middleware to gate access and return early when a request is not authorized.
Built-in middleware
ExceptionMiddleware— catches errors and returns JSON error responses.CookieSessionMiddleware— initializes cookies and sessions.CorsMiddleware— adds CORS headers and handles OPTIONS.LoggerMiddleware— logs request method, path, IP, and auth status.StaticFileMiddleware— serves files frompublic/with caching and range support.
Input Validation
Validate JSON or form data right from the request.
Validate JSON body
app.post('/register', (Context ctx) async {
final data = await ctx.req.validate({
'name': 'required|string|min:3',
'email': 'required|email',
'password': 'required|string|min:8',
}, messages: {
'email.required': 'Email is required.',
'email.email': 'Enter a valid email address.',
'password.min': 'Password must be at least :min characters.',
});
return ctx.res.json({'ok': true, 'data': data});
});
Validate form data
app.post('/profile', (Context ctx) async {
final data = await ctx.req.validateForm({
'bio': 'string|max:160',
'website': 'string',
});
return ctx.res.json({'ok': true, 'data': data});
});
Validation rules are pipe-separated (e.g. required|string|min:3). Supported rules include
required, string, int, double, bool,
email, regex:pattern, list, list:type,
confirmed, date, in:a,b,c, not_in:a,b,c,
min:n, and max:n.
By default, fields not present in your rules are treated as invalid. On failure, Flint throws a
ValidationException with field errors. Custom messages can target field.rule,
field, or rule keys and support :field, :min,
:max, and :value placeholders.
Authentication (TOTP)
Flint’s Auth helpers cover the common flows: register, login, password reset, and email verification. You can use these in your controllers or route handlers.
// Register (with additionalData)
app.post('/auth/register', (Context ctx) async {
final data = await ctx.req.json();
final user = await Auth.register(
email: data['email'],
password: data['password'],
name: data['name'],
additionalData: {
'role': data['role'],
'country': data['country'],
},
);
return ctx.res.json({'user': user});
});
// Login
app.post('/auth/login', (Context ctx) async {
final data = await ctx.req.json();
final result = await Auth.login(
data['email'],
data['password'],
throttleKey: ctx.req.ipAddress, // optional
);
return ctx.res.json(result); // { user, token }
});
Access + Refresh Tokens (Optional)
Refresh token support is opt-in. Keep it disabled unless your app needs long-lived sessions.
// Login with access + refresh tokens
app.post('/auth/login-with-refresh', (Context ctx) async {
final data = await ctx.req.json();
final tokens = await Auth.loginWithTokens(
data['email'],
data['password'],
throttleKey: ctx.req.ipAddress,
ipAddress: ctx.req.ipAddress,
userAgent: ctx.req.headers['user-agent'],
deviceName: 'web',
);
return ctx.res.json(tokens); // { user, accessToken, token, refreshToken? }
});
// Rotate refresh token and issue new access token
app.post('/auth/refresh', (Context ctx) async {
final data = await ctx.req.json();
final refreshed = await Auth.refreshAccessToken(
data['refreshToken'],
rotateRefreshToken: true,
ipAddress: ctx.req.ipAddress,
userAgent: ctx.req.headers['user-agent'],
);
if (refreshed == null) {
return ctx.res.status(401).json({'error': 'Invalid refresh token'});
}
return ctx.res.json(refreshed);
});
app.post('/auth/logout', (Context ctx) async {
final data = await ctx.req.json();
await Auth.revokeRefreshToken(data['refreshToken']);
return ctx.res.json({'ok': true});
});
// Password reset flow
app.post('/auth/password/forgot', (Context ctx) async {
final data = await ctx.req.json();
final token = await Auth.generatePasswordResetToken(data['email']);
return ctx.res.json({'token': token});
});
app.post('/auth/password/reset', (Context ctx) async {
final data = await ctx.req.json();
final ok = await Auth.resetPassword(
token: data['token'],
newPassword: data['password'],
);
return ctx.res.json({'reset': ok});
});
// Email verification
app.post('/auth/email/verify', (Context ctx) async {
final data = await ctx.req.json();
final ok = await Auth.verifyEmail(token: data['token']);
return ctx.res.json({'verified': ok});
});
OAuth Providers (Google, GitHub, Facebook, Apple)
Use Auth.providerRedirectUrl to build the OAuth URL, then exchange the callback code
or token using the provider helpers.
// 1) Redirect user to provider
app.get('/auth/google', (Context ctx) async {
final url = Auth.providerRedirectUrl(
provider: 'google',
redirectPath: '/auth/google/callback',
);
return ctx.res.redirect(url);
});
// 2) Handle callback
app.get('/auth/google/callback', (Context ctx) async {
final code = ctx.req.queryParam('code');
if (code == null) return ctx.res.status(400).json({'error': 'Missing code'});
final profile = await Auth.loginWithGoogle(
code: code,
callbackPath: '/auth/google/callback',
);
final user = await Auth.saveProviderUser(providerUserData: profile);
final token = Auth.generateToken(user);
return ctx.res.json({'user': user, 'token': token});
});
// GitHub (similar flow)
app.get('/auth/github', (Context ctx) async {
final url = Auth.providerRedirectUrl(
provider: 'github',
redirectPath: '/auth/github/callback',
);
return ctx.res.redirect(url);
});
app.get('/auth/github/callback', (Context ctx) async {
final code = ctx.req.queryParam('code');
if (code == null) return ctx.res.status(400).json({'error': 'Missing code'});
final profile = await Auth.loginWithGitHub(
code: code,
callbackPath: '/auth/github/callback',
);
final user = await Auth.saveProviderUser(providerUserData: profile);
final token = Auth.generateToken(user);
return ctx.res.json({'user': user, 'token': token});
});
// Facebook (access token or code)
app.post('/auth/facebook', (Context ctx) async {
final data = await ctx.req.json();
final profile = await Auth.loginWithFacebook(
accessToken: data['access_token'],
code: data['code'],
callbackPath: '/auth/facebook/callback',
);
final user = await Auth.saveProviderUser(providerUserData: profile);
final token = Auth.generateToken(user);
return ctx.res.json({'user': user, 'token': token});
});
// Apple Sign In
app.post('/auth/apple', (Context ctx) async {
final data = await ctx.req.json();
final profile = await Auth.loginWithApple(
identityToken: data['identity_token'],
authorizationCode: data['authorization_code'],
userData: data['user'],
);
final user = await Auth.saveProviderUser(providerUserData: profile);
final token = Auth.generateToken(user);
return ctx.res.json({'user': user, 'token': token});
});
You can add Time‑based One‑Time Password (TOTP) for 2‑factor authentication. The service below generates a secret, builds a QR/OTPAuth URL, and verifies codes.
TOTP is built into the framework. You can call TotpService.generateSecret(),
TotpService.buildOtpAuthUrl(), and TotpService.verifyCode() directly.
Flow: generate a secret per user, show the OTPAuth URL as a QR code, then verify the 6‑digit code on login.
// Controller-style example
// 1) Enable 2FA for a user
app.post('/auth/2fa/setup', (Context ctx) async {
final user = await User().find(1);
if (user == null) return ctx.res.status(404).json({'error': 'User not found'});
final secret = TotpService.generateSecret();
final otpUrl = TotpService.buildOtpAuthUrl(
secret: secret,
email: user.getAttribute('email'),
);
// Save secret to user (store securely)
await User().where('id', 1).update(data: {'totp_secret': secret});
return ctx.res.json({'otp_url': otpUrl});
});
// 2) Verify code during login
app.post('/auth/2fa/verify', (Context ctx) async {
final data = await ctx.req.json();
final user = await User().find(data['user_id']);
if (user == null) return ctx.res.status(404).json({'error': 'User not found'});
final secret = user.getAttribute('totp_secret');
final ok = TotpService.verifyCode(secret: secret, code: data['code']);
return ctx.res.json({'verified': ok});
});
Auth Environment Variables
These values live in your .env file and control the Auth system defaults.
AUTH_TABLE— users table name (default:users).AUTH_EMAIL_COLUMN— email column (default:email).AUTH_PASSWORD_COLUMN— password column (default:password).AUTH_NAME_COLUMN— name column (default:name).AUTH_PROVIDER_COLUMN— provider column (default:provider).AUTH_PROVIDER_ID_COLUMN— provider ID column (default:provider_id).REQUIRE_EMAIL_VERIFICATION—true/false.PASSWORD_MIN_LENGTH— minimum password length.JWT_SECRET— secret used to sign tokens.JWT_EXPIRY_HOURS- token lifetime in hours.AUTH_ACCESS_TOKEN_MINUTES- access token lifetime in minutes.AUTH_ENABLE_REFRESH_TOKENS- enable refresh token flow (default:false).AUTH_REFRESH_TOKEN_DAYS- refresh token lifetime in days.AUTH_ENABLE_LOGIN_THROTTLE- enable login lockout logic (default:false).AUTH_LOGIN_MAX_ATTEMPTS- failed attempts before temporary lock.AUTH_LOGIN_LOCK_MINUTES- lock duration in minutes.REDIRECT_BASE— base URL for OAuth redirects.GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETGITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETFACEBOOK_CLIENT_ID/FACEBOOK_CLIENT_SECRETAPPLE_CLIENT_ID/APPLE_TEAM_ID/APPLE_KEY_ID/APPLE_PRIVATE_KEY
Security
Flint includes security basics out of the box: password hashing and JWT tokens. Always use HTTPS
in production and store secrets in .env.
JWT Tokens
// Generate a JWT for a user
final token = Auth.generateToken({'id': userId, 'email': userEmail});
// Verify an incoming token
final payload = Auth.verifyToken(token);
if (payload == null) {
return ctx.res?.status(401).json({'error': 'Invalid token'});
}
Password Hashing
// Hash a password before saving
final hashed = Hashing().hash(password);
// Verify a password during login
final ok = Hashing().verify(password, hashedPassword);
Security Utilities (Direct Use)
You can use the low-level helpers directly when you are not using Auth.
// Choose algorithm explicitly
final hasher = Hashing(algorithm: HashingAlgorithm.bcrypt);
final digest = hasher.hash('secret');
final ok = hasher.verify('secret', digest);
// Raw JWT helper (uses JWT_SECRET from .env)
final jwt = FlintJwt(FlintEnv.get('JWT_SECRET'));
final token = jwt.sign({'id': 1});
final payload = jwt.verify(token);
Rate Limiting (Guidance)
For public APIs, add rate limiting at the reverse proxy (Nginx/Cloudflare) or implement a middleware that tracks requests per IP. This prevents abuse and protects your Auth endpoints.
Sessions & Cookies
Flint includes session helpers built on cookies. Use ctx.req.startSession(),
ctx.req.session, and ctx.req.destroySession() to manage login state.
Start a Session
app.post('/login', (Context ctx) async {
final user = {'id': 1, 'email': 'user@example.com'};
await ctx.req.startSession(user, ttl: Duration(hours: 8));
return ctx.res.json({'ok': true});
});
Cookies
Sessions are stored server-side and linked by a cookie named FLINTSESSID.
The cookie is set automatically when you call ctx.req.startSession().
final sessionId = ctx.req.cookies['FLINTSESSID'];
final isAuthed = sessionId != null;
You can also set your own cookies directly on the response:
app.get('/set-cookie', (Context ctx) async {
res
.setCookie('theme', 'dark', maxAge: 3600, httpOnly: true, sameSite: 'Lax')
.setCookie('lang', 'en', path: '/');
return ctx.res.json({'ok': true});
});
app.get('/clear-cookie', (Context ctx) async {
ctx.res.clearCookie('theme');
return ctx.res.json({'ok': true});
});
Read Session Data
app.get('/me', (Context ctx) async {
final session = await ctx.req.session;
if (session == null) return ctx.res.status(401).json({'error': 'Unauthorized'});
return ctx.res.json({'user': session});
});
Update or Destroy
await ctx.req.updateSession({'role': 'admin'});
await ctx.req.destroySession();
Sessions are powered by CookieSessionMiddleware, which is always registered by default.
Caching
Flint ships with a simple cache manager and built-in stores for memory and file caching.
Memory Cache
final cache = MemoryCacheStore();
cache.set('users.count', 42, ttl: Duration(minutes: 10));
final value = cache.get('users.count'); // 42
cache.delete('users.count');
File Cache
final cache = FileCacheStore(directory: 'storage/cache');
cache.set('report', {'ok': true}, ttl: Duration(hours: 1));
final report = cache.get('report');
cache.clear();
Storage
Use the Storage helper for file operations and path management.
Read & Write
final storage = Storage();
await storage.put('uploads/hello.txt', 'Hello Flint');
final contents = await storage.get('uploads/hello.txt');
await storage.delete('uploads/hello.txt');
Paths & Existence
final exists = await storage.exists('uploads/hello.txt');
final fullPath = storage.path('uploads/hello.txt');
Logging
Use Log for consistent framework logging and levels.
Basic Usage
Log.debug('Debug message');
Log.info('Server started');
Log.warning('Something looks off');
Log.error('Request failed', error: e);
Levels: debug, info, warning, error, critical.
Errors & Exceptions
Flint ships with ExceptionMiddleware enabled by default. It catches common exceptions
and returns JSON error responses automatically.
Validation Errors
Validation failures throw ValidationException, which is handled for you:
app.post('/users', (Context ctx) async {
final body = await ctx.req.validate({
'email': 'required|email',
'password': 'required|string|min:8',
});
return ctx.res.json({'ok': true, 'data': body});
});
Auth Errors
Auth helpers throw AuthException which is returned as a 401 JSON response.
Custom Errors
For custom error responses, return directly from your handler:
return ctx.res.status(403).json({'error': 'Forbidden'});
Helpers & Utils
Flint provides small helpers for common tasks. These are optional and mostly convenience utilities.
String Helpers
final id = Str.uuid();
final otp = Str.otp(6);
final slug = Str.slugify('Hello World!');
final token = Str.token(32);
final snake = Str.snake('UserProfile');
final camel = Str.camel('user_profile');
Path & URL Helpers
final uploads = storagePath('uploads/avatar.png');
final publicFile = publicPath('images/logo.png');
final assetUrl = assets('images/logo.png');
Some utilities under src/utils/ are internal and may change.
Controllers, Services & Routes
A clean structure separates HTTP routing (controllers) from business logic (services). This keeps routes small, testable, and scalable.
// lib/src/services/user_service.dart
class UserService {
Future
Flint ships with a standalone mail system for transactional emails. Use the low-level
Mail builder or the higher-level ViewMailable for HTML templates.
Auto Connect From .env
You can auto-configure mail from environment variables using MailConfig.load().
The framework calls this once when the app starts (unless you disable it). If you send mail
from a custom isolate, you are responsible for calling MailConfig.load() inside
that isolate before sending.
MailConfig.load();
When starting your app, you can also control automatic mail setup with the
autoConnectMail flag:
final app = Flint(
autoConnectMail: true, // default
);
Manual Setup
Mail.setup(
provider: MailProvider.gmail,
host: 'smtp.gmail.com',
port: 587,
username: 'you@gmail.com',
password: 'app-password',
fromAddress: 'noreply@yourapp.com',
fromName: 'Your App',
);
Send Immediately
await Mail()
.to('user@example.com')
.subject('Welcome')
.html('<p>Thanks for signing up.</p>')
.sendMail();
Queue In Background (Isolate)
Use queue() to send mail in a background isolate so your request returns fast.
await Mail()
.to('user@example.com')
.subject('Verify your email')
.html('<p>Click the link to verify your email.</p>')
.queue();
ViewMailable (HTML Templates)
ViewMailable renders a .flint.html template with data, then sends or queues it.
class WelcomeMail extends ViewMailable {
final String email;
final String name;
WelcomeMail({required this.email, required this.name});
@override
String get subject => 'Welcome';
@override
String get view => 'mail/views/welcome.flint.html';
@override
Map get data => {
'recipientName': name,
'recipientEmail': email,
};
@override
List get to => [email];
}
// Send or queue
await WelcomeMail(email: 'user@example.com', name: 'Ada').send();
await WelcomeMail(email: 'user@example.com', name: 'Ada').queue();
CLI Scaffold
The CLI generates both the mail class and the HTML view template.
flint --make-mail Welcome
Isolate
Use isolates to run heavy work off the main request thread. Flint provides
IsolateTask for single jobs and IsolateTaskQueue for batching.
Single Task
class ReportJob extends IsolateTask {
@override
Future performTask() async {
// heavy work here
return 'done';
}
}
await ReportJob().perform(
onDone: (result) => Log.debug('Result: $result'),
onError: (err) => Log.debug('Error: $err'),
);
Queue Multiple Tasks
final tasks = [
ReportJob(),
ReportJob(),
];
await IsolateTaskQueue.scheduleTasks(
tasks,
onDone: (task, result) => Log.debug('Done: $result'),
onError: (task, err) => Log.debug('Error: $err'),
);
CLI Scaffold
Generate a new isolate task with the CLI:
flint --make-isolate ReportJob
Swagger UI Docs
What is an app without docs? Flint Dart ships with auto‑generated Swagger UI so your team and
API users can explore endpoints instantly. Enable docs and visit http://localhost:3000/docs.
void main() {
final app = Flint(
enableSwaggerDocs: true,
);
app.listen(port: 3000);
}
Swagger docs are auto‑generated during development when you restart the server, so you stay productive.
The JSON is written to docs/swagger.json and Swagger UI reads it automatically at runtime.
Route Annotations
Use doc comments above routes to describe your API.
/// @summary Create a new user
/// @auth bearer
/// @response 201 Created
/// @response 400 Bad request
/// @param id path string required User ID
/// @query page integer optional Page number
/// @body {"name": "string", "email": "string"}
app.post('/users', controller.create);
@summary— short description shown in Swagger UI.@auth— auth type (default:bearer).@response— status code + description.@param— path or query parameters (name location type required).@query— query parameter (name type required).@body— JSON body schema example.@prefix— override prefix for a RouteGroup.@server— add server URLs for docs.
Database
Flint supports MySQL and PostgreSQL. Configure your connection in .env, and Flint will
auto‑connect on server start (unless you disable it).
# .env
DB_CONNECTION=mysql # or postgres
DB_HOST=localhost
DB_PORT=3306
DB_NAME=flint
DB_USER=root
DB_PASSWORD=secret
DB_SECURE=false
You can disable auto‑connect and call DB.connect() manually if you need dynamic tenants.
// Disable auto connect
final app = Flint(autoConnectDb: false);
// Manual connect
await DB.connect(database: 'flint');
DB_CONNECTION—mysqlorpostgres.DB_SECURE— settruefor secure MySQL connections.- Default ports: MySQL
3306, Postgres5432.
WebSockets
Flint includes a Socket.IO–like WebSocket API with events, rooms, and auth middleware.
WebSocket connections are established via explicit routes registered with app.websocket().
Once connected, you can emit or broadcast from anywhere using the global wsManager.
Server (Context style)
app.websocket('/chat', (Context ctx) {
final client = ctx.socket;
if (client == null) return;
client.on('message', (data) {
client.broadcastToRoom('chat', data.toString());
});
client.on('join', (data) {
client.join('chat');
});
});
Client
final ws = FlintWebSocketClient('wss://api.example.com/chat');
ws.on('message', (data) {
print('Received: $data');
});
ws.emit('join', {'room': 'chat'});
ws.emit('message', {'text': 'Hello world'});
Middleware
WebSocket routes support route middleware via the middlewares argument.
Use middleware to apply auth, logging, or validation logic around the connected socket handler.
app.websocket(
'/chat',
(Context ctx) {
// Connected clients only
final client = ctx.socket;
if (client == null) return;
},
middlewares: [AuthMiddleware()],
);
Tip: HTTP middleware does not run for WebSockets. Reuse shared logic by placing it in a middleware class used by both your HTTP routes and WebSocket routes.
Emit Without Touching a Route
You still need a route to accept the connection, but you do not need to be inside
that route to emit or broadcast. Use wsManager anywhere after clients are connected:
wsManager.emitToAll('server:notice', {'message': 'Hello, all clients'});
wsManager.emitToRoom('admins', 'audit', {'action': 'login'});
wsManager.emitToClient('clientId123', 'private', {'ok': true});
You can protect WebSocket routes the same way you protect HTTP routes by using middleware.
Route Groups
Group related endpoints under a prefix and mount them with app.routes().
import 'package:flint_dart/flint_dart.dart';
import 'package:flint_docs/controllers/auth_controller.dart';
class AuthRoutes extends RouteGroup {
@override
String get prefix => '/auth';
@override
void register(Flint app) {
final authController = AuthController();
app.post('/register', authController.register);
app.post('/login', authController.login);
}
}
void main() {
final app = Flint();
app.routes(AuthRoutes());
app.listen(port: 3000);
}
Views
Views are stored in lib/views. Render them using ctx.res.view():
app.get('/', (Context ctx) async {
return ctx.res.view('home', data: {
'title': 'Flint Docs'
});
});
A view file named lib/views/home.flint.html can extend a layout:
{{ extends('layouts.app') }}
{{ section('content') }}
<h1>{{ title }}</h1>
{{ endsection }}
Layouts live under lib/views/layouts and use {{ yield('content') }}.
Template Processors
Flint’s view engine supports these built‑in template features:
extends— layout inheritancesection/yield— slot content into layoutsinclude— partialsvariables—{{ ... }}interpolationif_statement—if/endiffor_loop—for/endforswitch_cases—switch/case/defaultcomment— template commentsassets— asset helper tagssession— session/error helpers in templates
Quick Examples
{{ if user }}
<p>Welcome, {{ user.name }}</p>
{{ endif }}
<ul>
{{ for item in items }}
<li>{{ item }}</li>
{{ endfor }}
</ul>
{{ include('partials.nav') }}
Layouts: extends + section + yield
{{ extends('layouts.app') }}
{{ section('title', 'Home') }}
{{ section('content') }}
<h1>Hello, {{ user.name }}</h1>
{{ endsection }}
In your layout, render sections with {{ yield('content') }}.
You can also use {{ section('sidebar') }}...{{ show }} for defaults.
Includes With Data
{{ include('partials.card', { "title": "Hello", "body": "..." }) }}
Conditionals
{{ if isAdmin }}
<span>Admin</span>
{{ elseif user }}
<span>User</span>
{{ else }}
<span>Guest</span>
{{ endif }}
Loops
{{ for item in items }}
<li>{{ item.name }}</li>
{{ endfor }}
{{ for i=0; i<3; i++ }}
<span>Index: {{ i }}</span>
{{ endfor }}
Switch/Case
{{ switch status }}
{{ case active }}Active{{ endcase }}
{{ case pending, paused }}Waiting{{ endcase }}
{{ default }}Unknown{{ enddefault }}
{{ endswitch }}
Variables, Comments, Assets, Sessions
Hello {{ user.email }}
{{! this is a comment }}
<img src="{{ assets('images/logo.png') }}" />
<p>Flash: {{ session('message') }}</p>
{{ if false }}...{{ endif }}
Flash Sessions in Views
Use response flash helpers to pass one-time messages to the next rendered view.
In the route/controller, call withSuccess() or withError() and redirect:
app.post('/settings', (Context ctx) async {
final data = await ctx.req.validate({'name': 'required|string|min:2'});
// ... save settings
return ctx.res
?.withSuccess('Settings updated successfully.')
.back(fallback: '/settings');
});
Then in your template, read them with session() and guard with hasSession():
{{ if false }}
<div class="alert alert-success">{{ session('success') }}</div>
{{ endif }}
{{ if false }}
<div class="alert alert-error">{{ session('error') }}</div>
{{ endif }}
Models & Tables
Flint Dart does not rely on separate migration files. Your table schema lives inside the model
using Table and Column. Only define your custom fields; the framework
manages id, created_at, and updated_at for you.
A Table describes the database table name. A Column describes one field (name, type, length, and options). If your model file feels too long, you can move the table definition into a separate file and reuse it in the model.
import 'package:flint_dart/model.dart';
import 'package:flint_dart/schema.dart';
class User extends Model<User> {
User() : super(() => User());
String get name => getAttribute("name");
String get email => getAttribute("email");
String get password => getAttribute("password");
String get profilePicUrl => getAttribute("profilePicUrl");
@override
Table get table => Table(
name: 'users',
columns: [
Column(name: 'name', type: ColumnType.string, length: 255),
Column(name: 'email', type: ColumnType.string, length: 255),
Column(
name: 'password',
type: ColumnType.string,
),
Column(
name: 'profilePicUrl',
type: ColumnType.string,
),
],
);
}
You can also define the table in its own file and reference it:
// lib/models/user_table.dart
import 'package:flint_dart/schema.dart';
final userTable = Table(
name: 'users',
columns: [
Column(name: 'name', type: ColumnType.string, length: 255),
Column(name: 'email', type: ColumnType.string, length: 255),
Column(name: 'password', type: ColumnType.string),
Column(name: 'profilePicUrl', type: ColumnType.string),
],
);
// lib/models/user_model.dart
class User extends Model<User> {
@override
Table get table => userTable;
}
Use getAttribute to read values from the internal map, and setAttribute or
setAttributes to assign values when creating or updating models.
ORM
The ORM is a friendly way to talk to your database without writing raw SQL. Think of a model like a “row helper” for a table. You call simple methods, and Flint builds the SQL for you.
Each line below shows a real task: finding a user, getting a list, creating a record, updating, and deleting. These are the core CRUD actions every new developer should learn first.
// READ: get a single user by ID
final user = await User().find(1);
// READ: list users with a filter
final users = await User()
.where('email', 'test@example.com')
.orderBy('created_at', desc: true)
.limit(10)
.get();
// CREATE: add a new user
final created = await User().create({
'name': 'Ada',
'email': 'ada@example.com',
'password': 'secret',
});
// UPDATE: change an existing user
await User()
.where('id', 1)
.update(data: {'name': 'Ada Lovelace'});
// DELETE: remove a user
await User().delete(1);
find(id)— fetch one record by primary key.where(...).get()— build a query and return a list of models.create(data)— insert a new record and return the created model.update(data: ...)— update matching records (usewhere()first).delete(id)— delete by primary key.
Tip: chain methods in the order you read them. “Where email is X, order by date, limit 10, get.” This makes the code easy to understand without knowing SQL.
More ORM Methods
These helpers cover common patterns like “find or create” and “upsert”.
// Save current model (create or update based on id)
final user = User()..setAttributes({'name': 'Ada', 'email': 'ada@example.com'});
await user.save();
// Find or create
final existing = await User().firstOrCreate(
where: {'email': 'ada@example.com'},
data: {'name': 'Ada'},
);
// Upsert (update if exists, otherwise create)
final upserted = await User().upsert(
where: {'email': 'ada@example.com'},
data: {'name': 'Ada Lovelace'},
);
// Upsert many
final results = await User().upsertMany([
{'where': {'email': 'a@ex.com'}, 'data': {'name': 'A'}},
{'where': {'email': 'b@ex.com'}, 'data': {'name': 'B'}},
]);
refresh(id?)— reload the model from the database.save()— create or update based on primary key.firstOrCreate(where, data)— get first match or create it.upsert(where, data)— update if found, else create.upsertMany(list)— batch upsert usingwhere+data.all()— get all records.whereSimple(field, value)— simple where without chaining.whereInSimple(field, values)— where in without chaining.countAll()— total count.countWhere(field, value)— count with filter.truncate()— delete all records in the table.
ORM Query
Chain query helpers step by step to build readable queries.
// 1) Start a query
final query = User();
// 2) Add filters
query.where('status', 'active');
// 3) Add ordering and limits
query.orderBy('created_at', desc: true).limit(10);
// 4) Execute
final users = await query.get();
ORM Relations
Relations are how models “connect” to each other (like users and posts).
Think of it like friends: a User can have many Posts, and a
Post belongs to one User. You declare that in the model’s
relations getter.
import 'package:flint_dart/model.dart';
import 'package:flint_dart/relations.dart';
class User extends Model<User> {
@override
Map<String, RelationDefinition> get relations => {
'posts': Relations.hasMany('posts', () => Post()),
};
}
class Post extends Model<Post> {
@override
Map<String, RelationDefinition> get relations => {
'author': Relations.belongsTo('author', () => User()),
};
}
The keys ('posts', 'author') are the names you will use when loading relations.
// Load relations when querying
final posts = await Post()
.withRelation('author')
.withRelation('comments')
.get();
// Load relations on a single model
final user = await User().find(1);
if (user != null) {
await user.load('posts');
}
Table Sync
To apply model table definitions to your database, register them in
lib/config/table_registry.dart and run the CLI sync command.
import 'dart:isolate';
import 'package:flint_dart/schema.dart';
import '../src/models/user_model.dart';
import '../src/models/post_model.dart';
void main(List<String> args, SendPort sendPort) {
runTableRegistry([
User().table,
PostModel().table,
], null, sendPort);
}
flint migrate
Use flint migrate --drop to drop and recreate tables when needed.
Deployment
Flint supports two production hosting paths: native build output and Dart runtime Docker. Choose based on your team workflow and target platform.
Option 1: Native build output
Use this when you want a compiled app artifact from the Flint build command.
flint build
cd build
./start.sh
This generates a deployable folder with your executable, docs, and runtime assets. Build mode is typically faster at runtime and works well for production environments.
Option 2: Docker runtime
Use this when you want a consistent runtime across local, staging, and production.
flint --make-docker
cd docker
docker compose up -d --build
The generated Docker setup installs dependencies with dart pub get and
runs your app with dart run lib/main.dart.
Best approach
- Both build and Docker are production-ready. This is not a small-app vs large-app decision.
- Choose based on your deployment preference: binary artifact workflow vs container workflow.
- Use Docker when you want strong runtime consistency across environments.
- Use build output when you prefer direct host deployment and minimal runtime dependencies.
- Keep secrets in environment variables, not in repository files.