DEV Community

Cover image for Flutter and Django: The Full-Stack Architecture We Use for Mobile Client Projects
Lycore Development
Lycore Development

Posted on

Flutter and Django: The Full-Stack Architecture We Use for Mobile Client Projects

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)
Enter fullscreen mode Exit fullscreen mode

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']),
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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,
}
Enter fullscreen mode Exit fullscreen mode
# 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()),
]
Enter fullscreen mode Exit fullscreen mode

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),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode
// 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()),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode
// 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);
}
Enter fullscreen mode Exit fullscreen mode

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)