When a client asks us to build a mobile app, the first decision is the backend. We use Django. When they ask why not Node, Firebase, or Supabase, the answer is the same every time: Django gives us a mature, battle-tested backend with an ORM, admin panel, migrations, and the full Python ecosystem — and we know how to run it reliably in production.
For the frontend, we use Flutter. One codebase, iOS and Android, fast enough for 99% of what business apps need, and a development experience that has gotten genuinely good over the past two years.
This post covers the architecture decisions we have settled on after building several Flutter + Django projects — authentication, data flow, error handling, and the things we got wrong the first time.
API design: DRF as the contract
The backbone of a Flutter + Django project is the Django REST Framework API. The API is the contract between the two sides of the stack, and getting it right upfront saves a lot of rework.
We use DRF with a consistent response envelope:
# api/serializers.py
from rest_framework.response import Response
from rest_framework.views import APIView
def success_response(data, status=200, meta=None):
body = {'success': True, 'data': data}
if meta:
body['meta'] = meta
return Response(body, status=status)
def error_response(message, code, status=400, errors=None):
body = {'success': False, 'error': {'message': message, 'code': code}}
if errors:
body['error']['errors'] = errors
return Response(body, status=status)
Every endpoint returns {"success": true, "data": {...}} or {"success": false, "error": {"message": "...", "code": "..."}}. The Flutter client knows exactly what to expect:
// lib/core/api_client.dart
class ApiResponse<T> {
final bool success;
final T? data;
final ApiError? error;
const ApiResponse({required this.success, this.data, this.error});
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) fromJson,
) {
if (json['success'] == true) {
return ApiResponse(
success: true,
data: fromJson(json['data']),
);
} else {
return ApiResponse(
success: false,
error: ApiError.fromJson(json['error']),
);
}
}
}
Consistent envelopes mean the Flutter client has one place to handle errors rather than handling different shapes per endpoint.
Authentication with JWT
We use Simple JWT for authentication:
pip install djangorestframework-simplejwt
# settings.py
from datetime import timedelta
INSTALLED_APPS = [..., 'rest_framework_simplejwt']
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
# urls.py
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
urlpatterns = [
path('api/auth/token/', TokenObtainPairView.as_view()),
path('api/auth/token/refresh/', TokenRefreshView.as_view()),
]
On the Flutter side, we use flutter_secure_storage to store tokens:
// lib/core/auth_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:dio/dio.dart';
class AuthService {
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
final _storage = const FlutterSecureStorage();
final Dio _dio;
AuthService(this._dio);
Future<void> saveTokens({
required String access,
required String refresh,
}) async {
await Future.wait([
_storage.write(key: _accessTokenKey, value: access),
_storage.write(key: _refreshTokenKey, value: refresh),
]);
}
Future<String?> getAccessToken() =>
_storage.read(key: _accessTokenKey);
Future<String?> getRefreshToken() =>
_storage.read(key: _refreshTokenKey);
Future<void> clearTokens() async {
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
]);
}
}
Automatic token refresh with Dio interceptors
The access token expires every 15 minutes. Rather than making the user re-authenticate, we intercept 401 responses and refresh the token automatically:
// lib/core/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
final AuthService _authService;
final Dio _dio;
AuthInterceptor(this._authService, this._dio);
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _authService.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401) {
final refreshed = await _refreshToken();
if (refreshed) {
// Retry the original request with the new token
final token = await _authService.getAccessToken();
err.requestOptions.headers['Authorization'] = 'Bearer $token';
final response = await _dio.fetch(err.requestOptions);
return handler.resolve(response);
} else {
// Refresh failed — clear tokens and redirect to login
await _authService.clearTokens();
}
}
handler.next(err);
}
Future<bool> _refreshToken() async {
final refreshToken = await _authService.getRefreshToken();
if (refreshToken == null) return false;
try {
final response = await Dio().post(
'${ApiConstants.baseUrl}/api/auth/token/refresh/',
data: {'refresh': refreshToken},
);
await _authService.saveTokens(
access: response.data['access'],
refresh: response.data['refresh'],
);
return true;
} catch (_) {
return false;
}
}
}
This is transparent to the rest of the app. API calls just work. If the access token expires, the interceptor refreshes it and retries without the caller knowing.
State management with Riverpod
We use Riverpod for state management. The pattern is: repositories handle API calls, providers expose state, widgets watch providers.
// lib/features/orders/order_repository.dart
class OrderRepository {
final Dio _dio;
OrderRepository(this._dio);
Future<List<Order>> getOrders({int page = 1}) async {
final response = await _dio.get(
'/api/orders/',
queryParameters: {'page': page, 'page_size': 20},
);
final apiResponse = ApiResponse.fromJson(
response.data,
(data) => (data['results'] as List)
.map((o) => Order.fromJson(o))
.toList(),
);
if (!apiResponse.success) throw ApiException(apiResponse.error!);
return apiResponse.data!;
}
}
// lib/features/orders/order_providers.dart
final orderRepositoryProvider = Provider<OrderRepository>((ref) {
return OrderRepository(ref.watch(dioProvider));
});
final ordersProvider = FutureProvider.autoDispose<List<Order>>((ref) {
return ref.watch(orderRepositoryProvider).getOrders();
});
// lib/features/orders/orders_screen.dart
class OrdersScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final ordersAsync = ref.watch(ordersProvider);
return ordersAsync.when(
data: (orders) => OrderList(orders: orders),
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => ErrorView(message: err.toString()),
);
}
}
The autoDispose modifier on the provider means it discards cached data when nothing is watching it — so navigating away and back triggers a fresh fetch. For data that should persist, omit autoDispose.
Pagination and infinite scroll
Most list views need pagination. Django provides it:
# api/views.py
from rest_framework.pagination import PageNumberPagination
from rest_framework import generics
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
def get_paginated_response(self, data):
return success_response(
data={
'results': data,
'count': self.page.paginator.count,
'next': self.get_next_link(),
'previous': self.get_previous_link(),
}
)
class OrderListView(generics.ListAPIView):
serializer_class = OrderSerializer
pagination_class = StandardPagination
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(
customer__user=self.request.user
).select_related('customer').order_by('-created_at')
On the Flutter side, we track pages in a Notifier:
@riverpod
class OrdersNotifier extends _$OrdersNotifier {
@override
AsyncValue<List<Order>> build() => const AsyncValue.data([]);
int _currentPage = 1;
bool _hasMore = true;
bool _loading = false;
Future<void> loadMore() async {
if (_loading || !_hasMore) return;
_loading = true;
final existing = state.value ?? [];
final result = await ref
.read(orderRepositoryProvider)
.getOrders(page: _currentPage);
_hasMore = result.length == 20;
_currentPage++;
_loading = false;
state = AsyncValue.data([...existing, ...result]);
}
}
File uploads
Uploading images (profile photos, document scans) from Flutter to Django:
# Django view
class ProfilePhotoView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser]
def post(self, request):
file = request.FILES.get('photo')
if not file:
return error_response('No file provided', 'no_file', status=400)
request.user.profile.photo = file
request.user.profile.save(update_fields=['photo'])
return success_response({'photo_url': request.user.profile.photo.url})
// Flutter
Future<void> uploadProfilePhoto(File imageFile) async {
final formData = FormData.fromMap({
'photo': await MultipartFile.fromFile(
imageFile.path,
filename: 'profile.jpg',
contentType: MediaType('image', 'jpeg'),
),
});
await _dio.post('/api/profile/photo/', data: formData);
}
The honest summary
Flutter + Django is a solid combination for client projects. Django handles what it is good at — data modelling, migrations, the admin panel, authentication, background tasks — and Flutter handles what it is good at — a smooth, native-feeling mobile UI from a single codebase.
The patterns that matter most: a consistent API envelope, JWT with automatic refresh, a clean repository layer in Flutter, and Riverpod for state. Get those right and the project scales cleanly.
What we got wrong early: inconsistent error shapes from the API, which meant the Flutter client had conditional logic for every endpoint. The envelope pattern fixed that. Define your API contract before you start building — it saves more time than any other decision.
Lycore builds Flutter and Django mobile applications for businesses — iOS and Android apps, REST APIs, and full-stack web and mobile products. Get in touch if you're building a mobile app.
Top comments (0)