Getting Started v1.0.0+27 Dart-first backend workflow

Build your first Flint app

Everything you need to create, run, and ship APIs in Dart.

Hot reload ORM included Views + templates

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 writes docs/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 from public/ 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_VERIFICATIONtrue/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_SECRET
  • GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
  • FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET
  • APPLE_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> listUsers() async {
    final users = await User().orderBy('created_at', desc: true).get();
    return {'data': users};
  }

  Future> createUser(Map data) async {
    final user = await User().create(data);
    return {'user': user};
  }
}

// lib/src/controllers/user_controller.dart
class UserController {
  final UserService service;
  UserController(this.service);

  Future index(Context ctx) async {
    final result = await service.listUsers();
    return ctx.res?.json(result);
  }

  Future store(Context ctx) async {
    final data = await ctx.req.json();
    final result = await service.createUser(data);
    return ctx.res?.json(result);
  }
}

// lib/routes/user_routes.dart
class UserRoutes extends RouteGroup {
  @override
  String get prefix => '/users';

  @override
  void register(Flint app) {
    final controller = UserController(UserService());
    app.get('/', controller.index);
    app.post('/', controller.store);
  }
}

Mail

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_CONNECTIONmysql or postgres.
  • DB_SECURE — set true for secure MySQL connections.
  • Default ports: MySQL 3306, Postgres 5432.

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 inheritance
  • section / yield — slot content into layouts
  • include — partials
  • variables{{ ... }} interpolation
  • if_statementif/endif
  • for_loopfor/endfor
  • switch_casesswitch/case/default
  • comment — template comments
  • assets — asset helper tags
  • session — 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 (use where() 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 using where + 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.
Global deploy CLI is planned soon, so one command will handle full deployment flow.