How to Build a Production-Ready Flutter App with Authentication, REST APIs, and Offline Support
Design a secure, maintainable, offline-capable Flutter application using Riverpod, Dio, Clean Architecture, secure token storage, and a local database.
Category: Flutter Development | Level: Intermediate to Advanced
Building a Flutter application that works in a demo environment is relatively straightforward. Building one that remains secure, responsive, reliable, and maintainable under real-world conditions requires a different engineering approach.
A production application must handle expired authentication tokens, intermittent connectivity, failed API requests, stale local data, concurrent updates, application restarts, and unexpected backend responses. It must also be testable and easy to maintain as the codebase grows.
In this guide, we will design a production-oriented Flutter application with authentication, REST API integration, local persistence, and offline support. Rather than placing everything inside a single widget, we will separate responsibilities into presentation, domain, and data layers.
- Design a scalable Flutter application architecture.
- Implement login, session persistence, and token refresh.
- Configure Dio for authenticated REST API requests.
- Cache server data in a local SQLite database.
- Display cached data when the device is offline.
- Synchronize local changes and handle conflicts safely.
- Test security, networking, persistence, and failure scenarios.
Table of Contents
- Production requirements
- Application architecture
- Dependencies and project structure
- Define the domain model
- Configure Dio and REST APIs
- Implement secure authentication
- Build the local persistence layer
- Implement an offline-first repository
- Manage application state with Riverpod
- Design reliable data synchronization
- Build a resilient user interface
- Security and performance
- Testing strategy
- Deployment and observability
- Production-readiness checklist
- Frequently asked questions
1. What Makes a Flutter App Production-Ready?
A production-ready app is not simply an app that successfully calls an API. It should continue to behave predictably when dependencies fail or users interact with the application under imperfect conditions.
Authentication
Secure sessions, token refresh, logout, and protected routes.
Networking
Timeouts, API errors, retries, request cancellation, and diagnostics.
Offline support
Local persistence, cached reads, pending writes, and synchronization.
Quality
Automated tests, observability, predictable state, and maintainability.
For this example, imagine an application that lets authenticated users view a list of tasks, open task details, and update task status. The app retrieves data from a backend and stores a local copy so previously synchronized tasks remain available without an internet connection.
Important distinction: Offline reads and offline writes are different capabilities. Reading cached data is relatively simple. Supporting offline edits requires a durable write queue, conflict-resolution rules, and a defined synchronization protocol.
2. Design the Application Architecture
We will use a layered architecture with Riverpod for dependency management and presentation state, Dio for HTTP communication, secure storage for session credentials, and SQLite for application data.
Widgets · Screens · Riverpod state
Entities · Repository contracts · Use cases
Repository implementations · DTOs · Data sources
Dio + REST API
SQLite + secure storage
Responsibilities of each layer
- Presentation: Displays data, collects user input, and reacts to loading, success, and error states.
- Domain: Defines business entities, repository contracts, and use cases without depending on Flutter widgets or a particular database.
- Data: Converts network responses into domain models, persists data, and coordinates remote and local data sources.
- Dependency injection: Provides the correct repository, API client, database, and authentication services to their consumers.
This separation makes the application easier to test and helps prevent networking, storage, and business rules from becoming tightly coupled to UI widgets.
Application data flow
READ FLOW
Screen → Riverpod → Repository → Local Database → UI
The screen can display previously stored data immediately, while a remote refresh runs when appropriate.
WRITE FLOW
User Action → Repository → Local Transaction + Pending Operation → Sync Worker → REST API
Pending operations remain durable until the server acknowledges them or a defined failure policy handles them.
3. Add Dependencies and Organize the Project
Install the core packages using Flutter's package manager:
flutter create production_flutter_app
cd production_flutter_app
flutter pub add flutter_riverpod
flutter pub add dio
flutter pub add flutter_secure_storage
flutter pub add sqflite
flutter pub add path
flutter pub add connectivity_plus
flutter pub add --dev mocktail
Use package versions compatible with your Flutter SDK and deployment targets. For an offline-write implementation, you may also choose a database abstraction or a dedicated synchronization library after evaluating its behavior and maintenance status.
Recommended project structure
lib/
├── app/
│ ├── app.dart
│ └── router.dart
├── core/
│ ├── config/
│ ├── errors/
│ ├── network/
│ │ └── dio_factory.dart
│ └── storage/
│ └── app_database.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── tasks/
│ ├── data/
│ │ ├── task_dto.dart
│ │ ├── task_local_data_source.dart
│ │ ├── task_remote_data_source.dart
│ │ └── task_repository_impl.dart
│ ├── domain/
│ │ ├── task.dart
│ │ └── task_repository.dart
│ └── presentation/
│ ├── task_providers.dart
│ └── task_screen.dart
└── main.dart
This is a suggested structure rather than a mandatory convention. For a small application, fewer files may be sufficient. For a large application, feature-based modules help teams work independently.
4. Define a Domain Model
Separate your domain model from the raw JSON representation returned by the backend. This prevents API-specific naming and serialization details from spreading throughout the application.
Create features/tasks/domain/task.dart:
class Task {
const Task({
required this.id,
required this.title,
required this.isCompleted,
required this.updatedAt,
});
final String id;
final String title;
final bool isCompleted;
final DateTime updatedAt;
Task copyWith({
String? title,
bool? isCompleted,
DateTime? updatedAt,
}) {
return Task(
id: id,
title: title ?? this.title,
isCompleted: isCompleted ?? this.isCompleted,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
The domain model represents what the application needs. A data-transfer object (DTO) handles converting between the backend's JSON format and the application's domain model.
For example, a backend may return a numeric task ID while another service returns a UUID. A DTO can normalize both into a string identifier without forcing the domain layer to know the backend's implementation.
5. Configure Dio for Production Networking
A reusable Dio client centralizes request configuration and error handling. Avoid creating a separate HTTP client in each screen or repository method.
Create core/network/dio_factory.dart:
import 'package:dio/dio.dart';
Dio createApiClient({
required String baseUrl,
}) {
return Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 15),
headers: const {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
);
}
Supply the base URL from environment-specific configuration rather than hardcoding development, staging, and production endpoints throughout the codebase.
Environment configuration
flutter run \
--dart-define=API_BASE_URL=https://api.example.com
Read the value in Dart:
const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.example.com',
);
Environment configuration helps select endpoints, but it does not protect secrets. Values compiled into a mobile application can be extracted. Private API credentials must remain on trusted servers.
Centralize API errors
Applications should not show raw Dio exceptions or server stack traces directly to end users. Translate low-level errors into meaningful application errors.
import 'package:dio/dio.dart';
String getApiErrorMessage(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return 'The request timed out. Please try again.';
case DioExceptionType.connectionError:
return 'Unable to connect. Check your connection.';
case DioExceptionType.badResponse:
final status = error.response?.statusCode;
if (status == 401) {
return 'Your session may have expired.';
}
if (status == 403) {
return 'You do not have permission to do this.';
}
if (status != null && status >= 500) {
return 'The service is temporarily unavailable.';
}
return 'The request could not be completed.';
default:
return 'Something went wrong. Please try again.';
}
}
For a larger codebase, prefer typed exceptions or a sealed failure hierarchy so the UI can respond to authentication, validation, network, and server failures independently.
6. Implement Secure Authentication
Authentication usually includes login, access-token management, refresh-token handling, session restoration, and logout. The exact protocol depends on your backend or identity provider.
Authentication lifecycle
Use secure storage for session credentials
Do not store authentication tokens in ordinary shared preferences or embed them in source code. Use platform-backed secure storage, with appropriate Android and iOS configuration.
A simple abstraction keeps storage details out of the rest of the application:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
TokenStorage(this._storage);
final FlutterSecureStorage _storage;
static const _accessKey = 'access_token';
static const _refreshKey = 'refresh_token';
Future saveTokens({
required String accessToken,
required String refreshToken,
}) async {
await _storage.write(
key: _accessKey,
value: accessToken,
);
await _storage.write(
key: _refreshKey,
value: refreshToken,
);
}
Future<String?> readAccessToken() {
return _storage.read(key: _accessKey);
}
Future<String?> readRefreshToken() {
return _storage.read(key: _refreshKey);
}
Future<void> clear() async {
await _storage.delete(key: _accessKey);
await _storage.delete(key: _refreshKey);
}
}
Production considerations: The storage abstraction is illustrative. A production implementation should account for partial-write failures, token rotation, logout behavior, platform security settings, and the identity provider's session policy. Never log token values.
Attach access tokens to API requests
A Dio interceptor can attach the current access token to protected requests:
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._readAccessToken);
final Future<String?> Function() _readAccessToken;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await _readAccessToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
Register this interceptor on the authenticated Dio client, and ensure the login and token-refresh endpoints do not depend on an access token that has already expired.
How should token refresh work?
When an access token expires, the backend may respond with HTTP 401. A refresh workflow should obtain a new token, update secure storage, and retry the original request at most once when the backend's authentication contract permits it.
- Use a separate refresh client or explicitly exclude the refresh endpoint from the normal authentication flow.
- Coordinate simultaneous 401 responses so multiple requests do not rotate the same refresh token independently.
- Mark retried requests to prevent infinite loops.
- Handle refresh-token expiration by clearing the session and returning the user to authentication.
- Do not treat every 401 as a token-expiration response unless the API contract defines it that way.
Token refresh requires coordination and error handling beyond a basic interceptor. Implement it according to the backend's token rotation, revocation, and retry semantics rather than blindly retrying every failed request.
7. Build the Local Persistence Layer
Offline support begins with a durable local representation of data. For relational data such as tasks, transactions, or user records, SQLite is a practical choice.
For this example, a task table might contain the following fields:
| Column | Purpose |
|---|---|
| id | Stable task identifier from the backend. |
| title | Task title. |
| is_completed | Current task completion state. |
| updated_at | Timestamp used for freshness and synchronization. |
| sync_status | Whether local changes are pending synchronization. |
Create a SQLite database
The following example initializes a simple table. A production database should also include migrations, appropriate indexes, transaction boundaries, and a strategy for handling schema upgrades.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
Future<Database> openAppDatabase() async {
final dbPath = await getDatabasesPath();
return openDatabase(
p.join(dbPath, 'production_app.db'),
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'synced'
)
''');
},
);
}
For a real application, keep database operations inside a dedicated local data source instead of calling SQL directly from screens or widgets.
Also consider whether locally stored business data needs encryption, how it is isolated between accounts, and whether it should be cleared or retained after logout.
8. Implement an Offline-First Repository
The repository is the key component connecting the remote API and the local database. The UI should not need to know whether a task came from the network or from cached storage.
Repository contract
Create features/tasks/domain/task_repository.dart:
import 'task.dart';
abstract interface class TaskRepository {
Future<List<Task>> getTasks();
Future<void> setCompleted({
required String id,
required bool isCompleted,
});
Future<void> synchronizePendingChanges();
}
The contract describes the application's required operations without exposing Dio, SQLite, or Riverpod to the domain layer.
Read data from the local database first
A common offline-first strategy is to return cached data immediately and refresh it from the server when possible. The repository can update the local database after a successful remote response.
class TaskRepositoryImpl implements TaskRepository {
TaskRepositoryImpl({
required this.remote,
required this.local,
});
final TaskRemoteDataSource remote;
final TaskLocalDataSource local;
@override
Future<List<Task>> getTasks() async {
final cachedTasks = await local.getTasks();
try {
final serverTasks = await remote.getTasks();
await local.upsertServerTasks(serverTasks);
return await local.getTasks();
} catch (_) {
if (cachedTasks.isNotEmpty) {
return cachedTasks;
}
rethrow;
}
}
@override
Future<void> setCompleted({
required String id,
required bool isCompleted,
}) async {
// Implement an atomic local update and durable
// pending-operation record here.
throw UnimplementedError(
'Implement transactional offline writes.',
);
}
@override
Future<void> synchronizePendingChanges() async {
// Read pending operations, send them to the server,
// and mark them complete only after acknowledgment.
throw UnimplementedError(
'Implement the synchronization worker.',
);
}
}
Important: This snippet illustrates the repository's read strategy, but it is not a complete offline-write implementation. The referenced data sources must be implemented, and production synchronization requires additional safeguards.
Notice that server errors fall back to cached data in this simplified example. In a production implementation, catch only errors that can reasonably be handled with a cache fallback. Do not silently hide programming errors, invalid data, or security failures.
How offline-first reads behave
Update the local database and refresh the UI.
Keep cached data and indicate that it may be stale.
For larger datasets, the application can expose a stream of local database changes so the UI updates automatically as synchronization writes new data. This often provides a more consistent experience than manually coordinating several in-memory copies.
9. Manage State with Riverpod
Riverpod provides a convenient way to expose repository operations and application state to Flutter widgets.
For example, the repository can be registered as a provider:
import 'package:flutter_riverpod/flutter_riverpod.dart';
final taskRepositoryProvider = Provider<TaskRepository>((ref) {
throw UnimplementedError(
'Register the repository implementation here.',
);
});
final tasksProvider = FutureProvider<List<Task>>((ref) async {
final repository = ref.watch(taskRepositoryProvider);
return repository.getTasks();
});
Register the real implementation at the application's composition root, where the database, API client, data sources, and repository are constructed. The placeholder above intentionally fails until that dependency registration is provided.
A simple screen can consume the provider using ref.watch(tasksProvider) and handle loading, error, and data states.
final tasksAsync = ref.watch(tasksProvider);
return tasksAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Text('Unable to load tasks.'),
),
data: (tasks) => ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
title: Text(task.title),
trailing: Icon(
task.isCompleted
? Icons.check_circle
: Icons.radio_button_unchecked,
),
);
},
),
);
In a fully offline-first UI, consider observing the local database directly rather than relying only on a one-time future. This makes the database the consistent source of truth when both user edits and background synchronization can change records.
10. Design Reliable Offline Synchronization
Supporting offline edits is one of the most challenging parts of a production mobile application. A user may modify a task while disconnected, close the app, and reconnect hours later. The change must survive application restarts and be delivered safely.
Use a durable pending-operation queue
When the user changes a task status, write the change and its pending operation to the local database in a single transaction. Do not depend on an in-memory queue that disappears when the app is terminated.
A pending operation may contain:
- A unique operation ID.
- The entity ID and requested change.
- The operation type, such as create, update, or delete.
- A creation timestamp and retry count.
- A synchronization status.
- An idempotency key when supported by the server.
Synchronization workflow
Prevent duplicate updates
A request can reach the server even if the client never receives its response. If the client blindly retries, the same operation may be applied twice.
For operations that need duplicate protection, use server-supported idempotency keys or stable operation IDs. The server should persist and recognize previously processed operations. A client-side flag alone cannot guarantee exactly-once delivery.
Handle conflicts explicitly
Suppose a task is edited on two devices before either device synchronizes. The application needs a defined policy for reconciling the changes.
- Last-write-wins: Use a server-defined timestamp or version rule when losing concurrent edits is acceptable.
- Version-based conflict detection: Send a version or ETag and reject conflicting updates so the client can reconcile them.
- Domain-specific merging: Merge independent fields or operations when the business rules allow it.
- User resolution: Ask the user to choose when conflicting changes have significant consequences.
Do not assume the device clock is authoritative. For important business records, use server-controlled versions or timestamps and define conflict behavior in the API contract.
Connectivity is not proof that the API is reachable
Packages such as connectivity_plus can report network-interface changes, but Wi-Fi or mobile data does not guarantee access to the internet or your backend. A captive portal, DNS failure, VPN, or server outage can still prevent a request from succeeding.
Use connectivity events as a synchronization trigger, not as the sole condition for declaring a request successful. Also provide an explicit retry action and consider platform background-execution limits.
11. Build a Resilient User Experience
A production UI should communicate whether displayed data is current, cached, or awaiting synchronization.
Display current data without unnecessary warnings.
Show the cached records and a clear offline indicator.
Show pending status without falsely claiming server confirmation.
Useful UI patterns include:
- Optimistic updates for safe, reversible user actions.
- Pull-to-refresh for manually requesting current server data.
- Retry actions for recoverable failures.
- Visible pending-sync indicators for offline edits.
- Accessible progress indicators and meaningful empty states.
- Conflict-resolution screens for important concurrent changes.
For payment, banking, and other high-impact workflows, never imply that a transaction has completed merely because a local update succeeded. Clearly distinguish locally queued actions from operations confirmed by the backend.
12. Security and Performance Best Practices
Authentication and data protection
- Use HTTPS and validate certificates using the platform's standard trust mechanisms.
- Store session credentials using platform-backed secure storage.
- Never include private API secrets in a mobile binary.
- Enforce authorization and input validation on the backend.
- Clear account-specific caches and pending operations appropriately when a user signs out.
- Prevent one account's cached records from appearing in another account's session.
- Avoid logging passwords, tokens, personal information, or sensitive request bodies.
Performance and reliability
- Use pagination for large datasets rather than loading everything at once.
- Add database indexes for frequently queried fields.
- Use bounded retries with exponential backoff and jitter for eligible transient failures.
- Do not automatically retry non-idempotent operations without a safe server-side strategy.
- Use cancellation or stale-response protection when users rapidly change screens or filters.
- Use database transactions for related local updates.
- Profile startup time, frame rendering, memory use, and network behavior on representative devices.
Privacy note: Offline storage increases the amount of data retained on the device. Store only what is necessary, define retention and logout behavior, and assess encryption requirements according to your threat model and regulatory obligations.
13. Build a Comprehensive Testing Strategy
Testing only the happy path is not sufficient for an application that depends on authentication, networking, and local persistence.
| Test type | What to verify |
|---|---|
| Unit tests | Model parsing, business rules, repository decisions, and error mapping. |
| API client tests | Headers, timeouts, HTTP failures, token refresh, and retry limits. |
| Database tests | Persistence, migrations, transactions, and account isolation. |
| Widget tests | Loading, cached content, errors, pending-sync labels, and retry actions. |
| Integration tests | Login, session restoration, offline edits, reconnection, and synchronization. |
| Security tests | Expired sessions, authorization boundaries, secret leakage, and account switching. |
Essential failure scenarios
- The app starts with no internet connection and an empty database.
- The app starts offline but has previously synchronized data.
- The access token expires while multiple API requests are in progress.
- The server accepts a write but the response is lost.
- The app terminates with pending synchronization operations.
- The user signs out while offline edits remain queued.
- A database migration occurs after an application upgrade.
- Two devices edit the same record concurrently.
Use mocked HTTP responses for deterministic tests and controlled backend environments for end-to-end tests. Avoid making every unit test depend on live internet access.
14. Prepare for Production Deployment
Architecture and code quality are only part of production readiness. Build and release processes should also be repeatable, observable, and secure.
Recommended release pipeline
Run static analysis and tests before generating a release build:
dart format --set-exit-if-changed .
flutter analyze
flutter test
flutter build appbundle --release
For iOS, also validate the release configuration and build using the required macOS/Xcode environment.
Monitor the application after release
- Track crashes, fatal errors, and startup failures.
- Measure API latency, timeout rates, and server error rates.
- Monitor synchronization failures and the age of pending operations.
- Use structured, privacy-conscious logs and correlation IDs.
- Define alerts and rollback procedures for problematic releases.
- Track app version and environment to identify release-specific problems.
Use a monitoring service that fits your organization’s privacy, security, and retention requirements. Do not attach credentials or sensitive business payloads to crash reports.
15. Production-Readiness Checklist
Use this checklist before releasing an authentication-enabled, offline-capable Flutter app.
Architecture
- ☐ Presentation, domain, and data responsibilities are separated.
- ☐ Dependencies are injected rather than constructed inside widgets.
- ☐ API DTOs and domain models have clear boundaries.
Authentication and security
- ☐ Tokens are stored securely and never logged.
- ☐ Token refresh handles concurrency and retry limits.
- ☐ Logout and account switching handle cached data safely.
Offline support
- ☐ Previously synchronized data is available offline.
- ☐ Offline writes use a durable queue and database transactions.
- ☐ Duplicate requests and synchronization conflicts are addressed.
Quality and release
- ☐ Unit, widget, database, and integration tests cover failure cases.
- ☐ CI runs formatting, static analysis, and automated tests.
- ☐ Monitoring, privacy controls, and rollback procedures are ready.
16. Frequently Asked Questions
Which architecture is best for a large Flutter application?
There is no universally best architecture. A feature-based structure with clearly separated presentation, domain, and data responsibilities is a strong starting point. Add abstraction where it improves testability or maintainability rather than creating layers without a clear purpose.
Can Riverpod manage authentication and API state?
Yes. Riverpod can expose authentication state, repositories, and asynchronous API data. Keep credential persistence and authentication protocol details inside dedicated services, and avoid making UI widgets responsible for managing tokens directly.
How do I make a Flutter app work without internet?
Persist the data needed by the user in a local database and read it when offline. If users must make changes offline, store those changes and pending operations durably, then synchronize them when the backend becomes reachable.
Is SQLite enough for offline-first applications?
SQLite is suitable for many relational offline use cases, but it does not automatically provide synchronization, conflict resolution, or cross-device consistency. Those behaviors must be implemented in the application and backend or provided by a suitable synchronization platform.
Should authentication tokens be stored in SharedPreferences?
Ordinary SharedPreferences is not designed for protecting sensitive credentials. Use platform-backed secure storage for tokens, and follow the security guidance for the specific identity provider and deployment platforms.
How can I prevent duplicate API requests?
Use appropriate request coordination and cancellation in the client, and use server-supported idempotency keys for operations where duplicate execution would be harmful. Client-side deduplication alone cannot guarantee exactly-once processing.
Conclusion
Building a production-ready Flutter app requires more than connecting a screen to a REST endpoint. Authentication, API communication, local persistence, offline behavior, synchronization, and error handling must work together as a coherent system.
By separating application layers, using Riverpod for state management, Dio for networking, secure storage for session credentials, and SQLite for local data, you can establish a solid foundation for a maintainable Flutter application.
The most important production principle is to design explicitly for failure: expired sessions, unavailable networks, interrupted writes, conflicting changes, and application restarts. Test these cases before release, not after users encounter them.
Keep Building Better Flutter Apps
Explore official documentation, test your architecture, and turn each component into a reusable, well-tested module.
Visit Flutter DocumentationOfficial Documentation and References
- Flutter documentation
- Riverpod package documentation
- Dio HTTP client documentation
- Flutter Secure Storage documentation
- SQLite database package documentation
- Connectivity Plus documentation
- Flutter testing guide
Editorial note: Code examples illustrate the architecture and selected implementation details. Data-source implementations, dependency registration, login and refresh endpoints, database migrations, durable synchronization, and platform-specific security configuration must be completed and tested for the target backend before production use. Verify package APIs against the versions resolved by your project.
Comments
Post a Comment