Designing Onboarding Flows That Convert: A Developer's Guide
Designing onboarding flows that convert: show value before sign-up, defer permissions, profile progressively, measure every step, and build it cleanly in Flutter.

Picture a new user’s first ninety seconds in a typical app. Three swipeable slides with stock illustrations. A sign-up form. Email verification. A notification permission prompt. A location permission prompt. A “tell us about yourself” survey. And then, finally, an empty home screen.
Every one of those steps loses people. Not because any single step is terrible, but because the user still hasn’t seen why the app is worth the effort.
Onboarding is usually designed by whoever is designing the rest of the app, then built by a developer in the last week. I think developers should have opinions here, because the decisions that hurt conversion are often technical: when you request permissions, whether the app can work without an account, what you track. Here’s what I push for.
Show the value before you ask for anything
The best onboarding I’ve seen barely looks like onboarding. You open the app and you’re already doing the thing it’s for.
Ask what the minimum is before a user gets something useful:
- A shopping app can let people browse and build a cart without an account.
- A recipe app can show recipes. Saving one can trigger sign-up.
- A fitness tracker can log a first workout locally and offer to create an account to back it up.
This isn’t just good UX. Apple’s App Review Guidelines say apps shouldn’t require registration unless it’s essential to the core functionality. Building for an anonymous first session also forces a cleaner architecture: data starts local and gets attached to an account later. (The account side of that is in production-ready authentication in Flutter.)
The moment you do ask someone to sign up, it should be because they just tried to do something that needs an account. “Create an account to save this” converts far better than a wall on launch.
Intro slides: keep them short, make them skippable
If you have intro slides at all, keep them to three or fewer, make each one about a benefit rather than a feature, and put a Skip button on every page. People who skip aren’t lost. They’re impatient, and the app should respect that.
A simple Flutter version with a “seen” flag:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final seenOnboarding = prefs.getBool('onboarding_seen') ?? false;
runApp(MyApp(showOnboarding: !seenOnboarding));
}
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key, required this.onDone});
final VoidCallback onDone;
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _controller = PageController();
int _page = 0;
static const _pageCount = 3;
Future<void> _finish() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('onboarding_seen', true);
widget.onDone();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isLast = _page == _pageCount - 1;
return Scaffold(
body: SafeArea(
child: Column(
children: [
Align(
alignment: Alignment.topRight,
child: TextButton(onPressed: _finish, child: const Text('Skip')),
),
Expanded(
child: PageView(
controller: _controller,
onPageChanged: (i) => setState(() => _page = i),
children: const [
BenefitPage(title: 'Find what you need fast'),
BenefitPage(title: 'Track everything in one place'),
BenefitPage(title: 'Works offline'),
],
),
),
Padding(
padding: const EdgeInsets.all(24),
child: FilledButton(
onPressed: isLast
? _finish
: () => _controller.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
child: Text(isLast ? 'Get started' : 'Next'),
),
),
],
),
),
);
}
}
One detail: save the flag when the user finishes or skips, not when the screen opens. Otherwise someone who force-quits on slide one never sees it again.
Ask for permissions in context, with a pre-prompt
On iOS you get one shot at the system permission dialog. If the user taps “Don’t Allow”, the only way back is the Settings app. Android lets you ask again, but repeated denials eventually stop the system dialog from appearing as well.
So never fire permission prompts on launch. Ask at the moment the permission makes obvious sense, and show your own explanation screen first:
- User taps “Notify me when my order ships”.
- Your screen explains what notifications they’ll get and how often, with “Turn on” and “Not now” buttons.
- Only on “Turn on” do you trigger the real system dialog.
Future<void> askForNotifications(BuildContext context) async {
final wantsIt = await showDialog<bool>(
context: context,
builder: (context) => const NotificationPrePrompt(),
);
if (wantsIt != true) return; // "Not now" leaves the system prompt unused
final status = await Permission.notification.request();
// record the outcome in analytics
}
That example uses permission_handler. Firebase Messaging’s requestPermission() does the same job for push specifically, covered in push notifications in Flutter with FCM. The point is that “Not now” costs you nothing, because you haven’t spent the system prompt.
Progressive profiling instead of a survey
Long onboarding questionnaires try to collect everything up front “so we can personalize the experience”. In practice they collect drop-offs.
Ask for each piece of information when it becomes useful. Need a shipping address? Ask at checkout. Want to know the user’s interests? Infer them from what they browse, or ask one question at a time on the home screen after they’ve used the app a bit. Each question then has an obvious reason, and the user is already invested enough to answer it.
When you do need a form, make it as painless as possible: correct keyboard types, autofill hints, inline validation.
Make sign-up one tap where you can
Social sign-in (Google, Apple) removes the password step entirely, which is the step a lot of people abandon. Two practical notes:
- If your iOS app offers third-party sign-in, Apple’s guidelines generally require offering Sign in with Apple, or an equivalent privacy-focused option, as well.
- Keep email sign-up available. Some people don’t want to link accounts, and some enterprise users can’t.
Also plan for account deletion from day one. Apple requires apps that support account creation to let users delete their account from within the app.
Empty states are onboarding too
The first time someone opens a list screen, it’s empty. That empty state is prime onboarding real estate, and most apps waste it on “No items found”.
A good empty state says what will appear here, and has one button that does the first action: “Add your first client”, “Browse products”, “Import contacts”. It teaches by doing, it only appears when relevant, and it disappears naturally once the user has data. A good empty state often does more teaching than any intro slide.
Measure every step
You can’t improve a funnel you can’t see. Log an analytics event at each onboarding step, so you can see exactly where people drop:
await FirebaseAnalytics.instance.logEvent(
name: 'onboarding_step',
parameters: {'step': 'notifications_preprompt', 'action': 'accepted'},
);
Firebase Analytics also has built-in logTutorialBegin() and logTutorialComplete() events, and logSignUp(signUpMethod: ...) for the account step.
Then look at the funnel: app opened, first core action, sign-up started, sign-up completed, permission granted. The biggest drop is where you start. Change one thing at a time, ship it, and compare. If you have remote config or feature flags, you can even test two versions side by side.
Keep the event names stable and documented. Renaming events every release makes historical comparisons useless.
Re-onboarding for new features
Onboarding doesn’t end after the first session. When you ship a significant feature, existing users need a lightweight introduction too, without being sent back through the whole flow.
What works well: a single tooltip or highlight on the new feature, shown once, stored with a versioned flag (feature_tour_v2_seen) so you can trigger it again for future changes. A short “What’s new” sheet after an update is fine too, as long as it’s dismissible in one tap and doesn’t appear on every launch.
Takeaways
- Let people experience the core value before asking them to sign up, and trigger sign-up from an action that needs it.
- Keep intro slides short, benefit-focused and skippable on every page.
- Request permissions in context, behind your own pre-prompt, so “Not now” doesn’t burn the system dialog.
- Collect profile information progressively, when each piece is actually needed.
- Treat empty states as onboarding, and measure every step of the funnel with stable analytics events.
The best onboarding is the one users don’t notice, because they were already using the app.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).