Flutter + Shelf + Local Storage: A Practical, Offline‑First Tutorial
Build a Flutter app with a Shelf backend: local JSON storage on the server, Hive caching on the client, and offline-first sync.
Image used for representation purposes only.
Overview
Building a Flutter app that talks to a tiny Dart backend is a great way to learn full‑stack Dart. In this tutorial you’ll:
- Spin up a minimal REST API using Shelf (Dart’s lightweight server library).
- Persist data locally on the server as a JSON file.
- Build a Flutter client that uses local storage (Hive) for offline‑first behavior and syncs with the API when online.
You’ll finish with a working to‑do app that survives server restarts and continues to work when the device is offline.
What you’ll build
- A Shelf API with routes: GET/POST/PUT/DELETE /todos
- Local file persistence on the server: data/todos.json
- A Flutter UI that stores a cached copy of todos in Hive
- Simple offline sync (optimistic updates + retry)
Prerequisites
- Dart SDK and Flutter SDK installed
- A recent emulator/simulator or a physical device
- Basic familiarity with Dart and Flutter widgets
Networking notes:
- Android emulator: use http://10.0.2.2:8080 to reach your machine.
- iOS simulator: http://localhost:8080 works.
- Physical devices: use your machine’s LAN IP, e.g., http://192.168.1.50:8080.
Project layout
We’ll keep the server and app side‑by‑side.
fullstack_dart/
server/ # Shelf API
bin/server.dart
pubspec.yaml
data/todos.json
app/ # Flutter client
lib/main.dart
pubspec.yaml
Step 1 — Create the Shelf API
From an empty parent folder:
mkdir fullstack_dart && cd fullstack_dart
mkdir server && cd server
dart create -t console-full .
Edit server/pubspec.yaml dependencies (use the latest versions available):
dependencies:
shelf: any
shelf_router: any
path: any
Step 2 — Implement the API with local file storage
Create bin/server.dart:
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';
class TodoStore {
final String filePath;
List<Map<String, dynamic>> _items = [];
TodoStore(this.filePath);
Future<void> init() async {
final file = File(filePath);
if (!await file.exists()) {
await file.create(recursive: true);
await file.writeAsString(jsonEncode({'items': []}));
}
final json = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
_items = List<Map<String, dynamic>>.from(json['items'] as List);
}
List<Map<String, dynamic>> all() => _items;
Map<String, dynamic>? byId(String id) =>
_items.cast<Map<String, dynamic>?>().firstWhere(
(e) => e!['id'] == id,
orElse: () => null,
);
Future<void> add(Map<String, dynamic> item) async {
_items.add(item);
await _persist();
}
Future<bool> update(String id, Map<String, dynamic> patch) async {
final idx = _items.indexWhere((e) => e['id'] == id);
if (idx == -1) return false;
_items[idx] = {
..._items[idx],
...patch,
'updatedAt': DateTime.now().toUtc().toIso8601String(),
};
await _persist();
return true;
}
Future<bool> remove(String id) async {
final removed = _items.removeWhere((e) => e['id'] == id) > 0;
if (removed) await _persist();
return removed;
}
Future<void> _persist() async {
final file = File(filePath);
await file.writeAsString(jsonEncode({'items': _items}));
}
}
Middleware cors() {
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept',
};
return (Handler inner) {
return (Request req) async {
if (req.method == 'OPTIONS') {
return Response.ok('', headers: headers);
}
final res = await inner(req);
return res.change(headers: headers);
};
};
}
void main(List<String> args) async {
final dataPath = p.join(Directory.current.path, 'data', 'todos.json');
final store = TodoStore(dataPath);
await store.init();
final router = Router()
..get('/todos', (Request req) {
return Response.ok(
jsonEncode({'items': store.all()}),
headers: {'content-type': 'application/json'},
);
})
..post('/todos', (Request req) async {
final body = await req.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
final now = DateTime.now().toUtc().toIso8601String();
final item = {
'id': DateTime.now().microsecondsSinceEpoch.toString(),
'title': (data['title'] ?? '').toString(),
'done': data['done'] == true,
'updatedAt': now,
};
await store.add(item);
return Response(201,
body: jsonEncode(item),
headers: {'content-type': 'application/json'});
})
..put('/todos/<id>', (Request req, String id) async {
final body = await req.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
final ok = await store.update(id, {
if (data.containsKey('title')) 'title': data['title'],
if (data.containsKey('done')) 'done': data['done'] == true,
});
if (!ok) return Response.notFound('Not found');
return Response.ok(jsonEncode(store.byId(id)),
headers: {'content-type': 'application/json'});
})
..delete('/todos/<id>', (Request req, String id) async {
final ok = await store.remove(id);
return ok ? Response.ok('OK') : Response.notFound('Not found');
});
final handler = const Pipeline()
.addMiddleware(logRequests())
.addMiddleware(cors())
.addHandler(router);
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await io.serve(handler, '0.0.0.0', port);
print('✓ Shelf API listening on http://${server.address.host}:${server.port}');
}
Run the server:
dart pub get
mkdir -p data && echo '{"items":[]}' > data/todos.json
dart run bin/server.dart
Quick tests:
# Create
curl -X POST http://localhost:8080/todos \
-H 'content-type: application/json' \
-d '{"title":"Learn Shelf","done":false}'
# List
curl http://localhost:8080/todos
Your data persists in server/data/todos.json even after restarts.
Step 3 — Create the Flutter app
From the project root:
cd ..
flutter create app
cd app
Add dependencies (use current versions):
dependencies:
flutter:
sdk: flutter
http: any
hive: any
hive_flutter: any
connectivity_plus: any
Initialize Hive and build a simple offline‑first client.
Step 4 — Flutter data model and storage
lib/main.dart:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:connectivity_plus/connectivity_plus.dart';
const String kBoxTodos = 'todos';
// Configure for your target:
// - Android emulator: http://10.0.2.2:8080
// - iOS simulator / Web: http://localhost:8080
const String serverBaseUrl = String.fromEnvironment(
'SERVER_URL',
defaultValue: 'http://10.0.2.2:8080',
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
await Hive.openBox(kBoxTodos);
runApp(const TodoApp());
}
class TodoApp extends StatefulWidget {
const TodoApp({super.key});
@override
State<TodoApp> createState() => _TodoAppState();
}
class _TodoAppState extends State<TodoApp> {
late final Box _box;
late final Api _api;
late final Connectivity _connectivity;
@override
void initState() {
super.initState();
_box = Hive.box(kBoxTodos);
_api = Api(serverBaseUrl, _box);
_connectivity = Connectivity();
// Initial sync attempt.
_api.refreshFromServer();
// Retry on connectivity changes.
_connectivity.onConnectivityChanged.listen((_) async {
await _api.syncPending();
await _api.refreshFromServer();
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter + Shelf + Local Storage',
home: Scaffold(
appBar: AppBar(title: const Text('Todos')),
body: Column(children: [
const _AddTodoField(),
Expanded(
child: ValueListenableBuilder(
valueListenable: _box.listenable(),
builder: (context, Box box, _) {
final items = List<Map>.from(box.values.cast<Map>());
items.sort((a, b) =>
(b['updatedAt'] ?? '').compareTo(a['updatedAt'] ?? ''));
if (items.isEmpty) {
return const Center(child: Text('No todos yet'));
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, i) {
final todo = items[i];
final id = todo['id'];
final pending = todo['pending'] == true;
return Dismissible(
key: ValueKey(id),
background: Container(color: Colors.red),
onDismissed: (_) => _api.deleteTodo(id),
child: CheckboxListTile(
secondary: pending
? const Icon(Icons.sync, color: Colors.orange)
: null,
title: Text(todo['title'] ?? ''),
value: todo['done'] == true,
onChanged: (v) => _api.toggleDone(id, v ?? false),
),
);
},
);
},
),
),
]),
),
);
}
}
class _AddTodoField extends StatefulWidget {
const _AddTodoField();
@override
State<_AddTodoField> createState() => _AddTodoFieldState();
}
class _AddTodoFieldState extends State<_AddTodoField> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
final api = (context.findAncestorStateOfType<_TodoAppState>()?._api)!;
return Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
Expanded(
child: TextField(
controller: _controller,
decoration:
const InputDecoration(hintText: 'Add a task and press Enter'),
onSubmitted: (v) async {
if (v.trim().isEmpty) return;
await api.createTodo(v.trim());
_controller.clear();
},
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
final v = _controller.text.trim();
if (v.isEmpty) return;
await api.createTodo(v);
_controller.clear();
},
)
]),
);
}
}
class Api {
final String baseUrl;
final Box box;
final http.Client _client = http.Client();
Api(this.baseUrl, this.box);
List<Map> get _items => List<Map>.from(box.values.cast<Map>());
Future<void> refreshFromServer() async {
try {
final res = await _client.get(Uri.parse('$baseUrl/todos'));
if (res.statusCode == 200) {
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
final serverItems = List<Map<String, dynamic>>.from(decoded['items']);
await _replaceLocal(serverItems);
}
} catch (_) {
// No network — rely on local cache.
}
}
Future<void> _replaceLocal(List<Map<String, dynamic>> serverItems) async {
await box.clear();
for (final item in serverItems) {
await box.add({
'id': item['id'],
'title': item['title'],
'done': item['done'] == true,
'updatedAt': item['updatedAt'],
'pending': false,
});
}
}
Future<void> createTodo(String title) async {
final local = {
'id': DateTime.now().microsecondsSinceEpoch.toString(),
'title': title,
'done': false,
'updatedAt': DateTime.now().toUtc().toIso8601String(),
'pending': true,
};
await box.add(local); // Optimistic local write
try {
final res = await _client.post(
Uri.parse('$baseUrl/todos'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'title': title, 'done': false}),
);
if (res.statusCode == 201) {
final created = jsonDecode(res.body) as Map<String, dynamic>;
await _upsertLocal(created, pending: false);
}
} catch (_) {
// Stay pending; will sync later.
}
}
Future<void> toggleDone(String id, bool done) async {
final current = _items.firstWhere((e) => e['id'] == id);
await _upsertLocal({...current, 'done': done, 'pending': true});
try {
final res = await _client.put(
Uri.parse('$baseUrl/todos/$id'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'done': done}),
);
if (res.statusCode == 200) {
final updated = jsonDecode(res.body) as Map<String, dynamic>;
await _upsertLocal(updated, pending: false);
}
} catch (_) {}
}
Future<void> deleteTodo(String id) async {
// Optimistic delete locally
final idx = _indexById(id);
if (idx == -1) return;
final backup = Map<String, dynamic>.from(box.getAt(idx));
await box.deleteAt(idx);
try {
await _client.delete(Uri.parse('$baseUrl/todos/$id'));
} catch (_) {
// Restore if offline delete failed.
await box.add(backup);
}
}
Future<void> syncPending() async {
final pendings = _items.where((e) => e['pending'] == true).toList();
for (final t in pendings) {
try {
final res = await _client.put(
Uri.parse('$baseUrl/todos/${t['id']}'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'title': t['title'], 'done': t['done']}),
);
if (res.statusCode == 200) {
final updated = jsonDecode(res.body) as Map<String, dynamic>;
await _upsertLocal(updated, pending: false);
}
} catch (_) {}
}
}
Future<void> _upsertLocal(Map<String, dynamic> item, {bool? pending}) async {
final idx = _indexById(item['id']);
final merged = {
'id': item['id'],
'title': item['title'],
'done': item['done'] == true,
'updatedAt': item['updatedAt'] ?? DateTime.now().toUtc().toIso8601String(),
'pending': pending ?? false,
};
if (idx == -1) {
await box.add(merged);
} else {
await box.putAt(idx, merged);
}
}
int _indexById(String id) {
for (var i = 0; i < box.length; i++) {
final m = box.getAt(i) as Map;
if (m['id'] == id) return i;
}
return -1;
}
}
Run the Flutter app. Example for the Android emulator:
flutter run --dart-define=SERVER_URL=http://10.0.2.2:8080
iOS simulator or web:
flutter run -d ios --dart-define=SERVER_URL=http://localhost:8080
# or
flutter run -d chrome --dart-define=SERVER_URL=http://localhost:8080
How offline‑first works here
- All writes go to Hive immediately (optimistic UI).
- A best‑effort network call follows. If it fails, the record is flagged pending: true.
- On app start and whenever connectivity changes, pending items are retried.
- The server’s updatedAt timestamp provides a simple last‑write‑wins strategy.
For larger apps consider conflict resolution per field, revision IDs, and a durable outbox table.
Useful enhancements (optional)
- Validation: reject empty titles at the API layer.
- Indexing: store todos by ID in Hive for O(1) upserts.
- Background sync: use WorkManager (Android) or BGTaskScheduler (iOS) via plugins.
- Auth: add a token header and verify in a Shelf middleware.
- Encryption at rest: use Hive’s encryption cipher for sensitive data.
Troubleshooting
- Android cannot reach server: ensure you use 10.0.2.2, not localhost.
- CORS errors in Flutter web: confirm the Shelf CORS middleware is active and that your browser points to http://localhost:8080.
- Permission denied writing todos.json: check the server process has write access to the server/data directory.
- Hot reload not reflecting API changes: restart the server process; Dart server code does not hot‑reload like Flutter.
Next steps
- Replace the JSON file with SQLite (package:sqlite3) or a higher‑level ORM like Drift on the server.
- Add pagination and filtering on the API for large lists.
- Create integration tests using package:test and flutter_test.
You now have a compact, end‑to‑end Dart stack: Flutter UI, Shelf API, and local storage on both sides. This foundation scales well—iterate on the API, improve local caching, and evolve into a robust offline‑capable app.
Related Posts
Flutter REST API Integration: A Practical, Production-Ready Guide
End-to-end Flutter REST API integration: http vs Dio, auth, error handling, caching, pagination, testing, security, and best practices.
Flutter CustomScrollView and Slivers: The Complete Guide with Patterns and Code
Master Flutter’s CustomScrollView and slivers with patterns, code, and performance tips to build fast, flexible, sticky, and collapsible scroll UIs.
From Zero to Published: Building and Releasing a Flutter Package on pub.dev
Step-by-step guide to build, test, document, and publish Flutter packages and plugins to pub.dev with CI, versioning, and scoring tips.