Shorebird in Flutter: solved exercise with code push and OTA updates

Shorebird in Flutter: solved exercise with code push

Shorebird is the code push service for Flutter: it lets you ship Dart code updates directly to users without going through Google Play or App Store review. It was created by the original Flutter engineering team.

The concept is the same as React Native CodePush: you compile a patch of your Dart changes, upload it to Shorebird, and the app downloads and installs it the next time it opens.

Problem statement

Configure a Flutter project with Shorebird that:

  • Initializes the integration with shorebird init.
  • Checks for available updates on startup.
  • Downloads and installs the patch in the background.
  • Informs the user of the status with a minimal UI.
  • Displays the current patch number on the client side.

Dependencies

1
2
3
4
dependencies:
  flutter:
    sdk: flutter
  shorebird_code_push: ^1.5.0

Configuration

1. Install the Shorebird CLI:

1
2
3
curl --proto '=https' --tlsv1.2 \
  https://raw.githubusercontent.com/shorebirdtech/shorebird/main/install.sh \
  -sSf | bash

Verify the installation with shorebird --version.

2. Create an account and authenticate:

1
shorebird login

3. Initialize Shorebird in the Flutter project:

1
2
cd my_flutter_app
shorebird init

This adds a shorebird.yaml with the project’s app_id.

4. Create the first release (production build uploaded to Shorebird):

1
2
3
4
5
# Android
shorebird release android

# iOS
shorebird release ios

Distribute this build on Google Play / App Store like any normal release.

5. When you have code changes to ship as a patch:

1
2
3
4
5
# Android
shorebird patch android

# iOS
shorebird patch ios

Users will receive the patch the next time they open the app.

Full solution

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import 'package:flutter/material.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';

Future<void> main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shorebird Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.green),
      home: const UpdatePage(),
    );
  }
}

class UpdatePage extends StatefulWidget {
  const UpdatePage({super.key});

  @override
  State<UpdatePage> createState() => _UpdatePageState();
}

class _UpdatePageState extends State<UpdatePage> {
  final _updater = ShorebirdUpdater();
  UpdateStatus _status = UpdateStatus.upToDate;
  int? _currentPatch;
  bool _checking = false;
  bool _downloading = false;

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    // isAvailable is false in debug mode and iOS simulators
    if (!_updater.isAvailable) return;

    final info = await _updater.readCurrentPatch();
    if (mounted) setState(() => _currentPatch = info?.number);

    await _checkForUpdate();
  }

  Future<void> _checkForUpdate() async {
    if (!_updater.isAvailable) return;

    setState(() => _checking = true);
    try {
      final status = await _updater.checkForUpdate();
      if (mounted) setState(() => _status = status);
    } catch (e) {
      debugPrint('Error checking for update: $e');
    } finally {
      if (mounted) setState(() => _checking = false);
    }
  }

  Future<void> _downloadUpdate() async {
    setState(() => _downloading = true);
    try {
      await _updater.update();
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text('Patch installed. Restart the app to apply it.'),
            duration: Duration(seconds: 4),
          ),
        );
        setState(() => _status = UpdateStatus.upToDate);
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context)
            .showSnackBar(SnackBar(content: Text('Error: $e')));
      }
    } finally {
      if (mounted) setState(() => _downloading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shorebird Updates'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            tooltip: 'Check for update',
            onPressed: _checking ? null : _checkForUpdate,
          ),
        ],
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              if (_currentPatch != null)
                Text('Current patch: #$_currentPatch',
                    style: Theme.of(context).textTheme.bodySmall)
              else
                const Text('Base release (no patches applied)',
                    style: TextStyle(color: Colors.grey)),
              const SizedBox(height: 32),
              if (!_updater.isAvailable)
                const _StatusCard(
                  icon: Icons.info_outline,
                  title: 'Shorebird not available',
                  subtitle:
                      'Only works in release builds. Disabled in debug mode and simulators.',
                  color: Colors.grey,
                )
              else if (_checking)
                const Column(children: [
                  CircularProgressIndicator(),
                  SizedBox(height: 16),
                  Text('Checking for updates...'),
                ])
              else if (_status == UpdateStatus.outdated)
                _StatusCard(
                  icon: Icons.system_update,
                  title: 'Update available',
                  subtitle: 'A new patch is ready to install.',
                  color: Colors.green,
                  action: FilledButton.icon(
                    onPressed: _downloading ? null : _downloadUpdate,
                    icon: _downloading
                        ? const SizedBox(
                            width: 16,
                            height: 16,
                            child: CircularProgressIndicator(
                                strokeWidth: 2, color: Colors.white),
                          )
                        : const Icon(Icons.download),
                    label:
                        Text(_downloading ? 'Downloading...' : 'Install patch'),
                  ),
                )
              else
                const _StatusCard(
                  icon: Icons.check_circle,
                  title: 'App is up to date',
                  subtitle: 'You are on the latest available version.',
                  color: Colors.teal,
                ),
            ],
          ),
        ),
      ),
    );
  }
}

class _StatusCard extends StatelessWidget {
  final IconData icon;
  final String title;
  final String subtitle;
  final Color color;
  final Widget? action;

  const _StatusCard({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.color,
    this.action,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(children: [
          Icon(icon, size: 48, color: color),
          const SizedBox(height: 12),
          Text(title,
              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          const SizedBox(height: 6),
          Text(subtitle,
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.grey)),
          if (action != null) ...[const SizedBox(height: 16), action!],
        ]),
      ),
    );
  }
}

Key concepts

ConceptDetail
shorebird releaseFull build distributed through the stores
shorebird patchPatch containing only Dart code changes
ShorebirdUpdaterFlutter client to check and install patches
updater.isAvailablefalse in debug mode and simulators (release only)
updater.checkForUpdate()Queries the server for a newer patch
updater.readCurrentPatch()Number of the currently installed patch
updater.update()Downloads and installs the patch (applied on next restart)
UpdateStatus.outdatedA patch is available
UpdateStatus.upToDateApp is on the latest version

Important limitations

  • Dart only: Shorebird patches compiled Dart code. Changes to native code (Kotlin/Swift), assets, images, or pub.dev package dependencies cannot be shipped as a patch.
  • Patch applied on restart: updater.update() downloads the patch, but it activates the next time the user opens the app.
  • iOS: Shorebird complies with Apple’s policies; it only modifies Dart code, not native code.
  • Free plan: includes a limited number of patches per month; check current pricing at shorebird.dev.

Common mistakes

  • isAvailable always false during development: correct by design. Always test with a release build and internal distribution.
  • Changing assets and expecting them to arrive with the patch: does not work. Dart code only.
  • Forgetting to run shorebird release before the first shorebird patch: Shorebird needs a base release to calculate the patch diff against.

Practical application

Shorebird is especially valuable for apps with slow Apple review cycles, urgent bug fixes in production, or teams with frequent deployment pipelines. Combine it with feature flags to activate new functionality in a controlled way without republishing to the stores.

Guided practice and next step

FAQ

Does Shorebird violate Apple’s policies?

No. Shorebird only modifies compiled Dart code (AOT patches), not native code. Apple allows updating application logic as long as you don’t change the core functionality without review.

How long does it take for a patch to reach users?

Patches are downloaded the next time the app opens with a connection. They are applied on the following launch. The total time for most users is hours, not days.

Can I roll back a patch?

Yes. From the Shorebird dashboard you can promote a previous release as active, which causes apps to discard the faulty patch and revert to the previous state.