Force Update in Flutter (Part 7): Custom API with Dart Shelf
Force Update series: 1. Why you need it · 2. Upgrader · 3. Remote config intro · 4. force_update_helper · 5. GitHub Gist · 6. Firebase Remote Config · 7. Dart Shelf API
Gist is fine for small apps. Firebase is fine when you already pay the Firebase tax. When you have (or want) your own backend, a single endpoint is the cleanest source of truth:
GET /required_version
→ 200 text/plain
1.4.2
This part uses Dart Shelf so the whole stack stays in Dart — same language as the Flutter client.
Why a custom endpoint
- No Gist rate limits
- No Firebase if you do not need it
- Easy multi-env (
dev/stg/prod) with different floors - Can later expand to JSON: soft version, messages, store URLs, kill switches
- Fits existing auth, logging, and deploy pipelines
Minimal Shelf server
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
void main() async {
final app = Router();
app.get('/required_version', (Request request) {
final version = Platform.environment['REQUIRED_VERSION'] ?? '1.0.0';
return Response.ok(
version,
headers: {'content-type': 'text/plain; charset=utf-8'},
);
});
final handler = Pipeline()
.addMiddleware(logRequests())
.addHandler(app.call);
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, port);
print('Listening on ${server.address.host}:${server.port}');
}
Flutter client
ForceUpdateClient(
fetchRequiredVersion: () async {
// TODO: production URL from env / flavor
const baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.0.2.2:8080', // Android emulator → host
);
final response = await dio.get('$baseUrl/required_version');
return (response.data as String).trim();
},
iosAppStoreId: Env.appStoreId,
);
Notes:
- iOS simulator often uses
http://127.0.0.1:8080 - Android emulator uses
10.0.2.2for host loopback - Real devices need your LAN IP or a deployed HTTPS URL
- Cleartext HTTP needs network security config / ATS exceptions — prefer HTTPS in prod
Flavors map to base URLs
| Flavor | Base URL example |
|---|---|
| dev | https://dev-api.your-app.com |
| stg | https://stg-api.your-app.com |
| prod | https://api.your-app.com |
Each environment can set REQUIRED_VERSION independently so QA can force-update staging without bricking production.
Deploy options (pick one)
- Globe / edge Dart hosts — designed for Dart servers
- Docker → Cloud Run, ECS, Azure Container Apps
- VM / VPS — simple
dart runor AOT binary behind Caddy/Nginx - Existing backend — skip Shelf and add
GET /required_versionto Nest, Rails, Go, etc. The Flutter side does not care
Beyond this course-length note: deploying Dart backends style guides exist; the rule is HTTPS + env vars + health checks.
Growing the contract
Start with plain text. When product needs more:
{
"required_version": "1.4.0",
"recommended_version": "1.5.1",
"message": "This version can no longer sync. Please update.",
"force": true
}
Keep the client tolerant: if JSON parse fails, fall back to safe defaults.
Production checklist
- HTTPS only in prod
-
REQUIRED_VERSIONset per environment - Flutter flavor points at correct base URL
- Caching + timeout on the client
- Raise version only after store rollout
- Logging/metrics on the endpoint
Series wrap-up
| Part | Tool | Best for |
|---|---|---|
| 1 | Concepts | Policy & soft vs hard |
| 2 | upgrader | Always match store |
| 3 | Remote min version | Kill switch design |
| 4 | force_update_helper | Client glue |
| 5 | Gist | Zero backend |
| 6 | Firebase RC | Existing Firebase apps |
| 7 | Shelf / API | Own backend, multi-env |
My default for shipping apps: remote min version + helper + API or Firebase, hard only when it matters, Shorebird for Dart-only fire drills.
← Previous Part 6: Firebase Remote Config
Series start → Part 1: Why force update



