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.

ASOasis
9 min read
Flutter + Shelf + Local Storage: A Practical, Offline‑First Tutorial

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