◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/App Dev
◇ App Dev/2026-06-03Intermediate

Unifying Onboarding Across Six Wallpaper Apps: What One Month of First-Day Retention Showed Me

I folded the onboarding flows of six wallpaper apps scaffolded with Rork into a single config-driven component and watched first-day retention and push opt-in for a month. Here is an honest, operational note on what moved and what didn't.

Rork587Onboarding8Wallpaper3Retention13Expo219React Native239

When you run six wallpaper apps in parallel, you eventually notice that you have rebuilt the same "first three screens" six times. The palette and the copy drift apart, and you lose track of which app got which fix. In May 2026 I finally collapsed all of it into one shared onboarding flow, ran it for a month, and wrote down honestly how the numbers moved before and after.

Why I rebuilt all six at once

The trigger wasn't revenue or traffic. It was the pain of maintenance. I would fix the wording on the second onboarding screen in one app and leave it stale in another. The moment I asked for push permission also varied per app—"right at launch" here, "after the first screen" there—so even when I compared opt-in rates, I couldn't isolate what actually worked.

Both of my grandfathers were temple carpenters, and I grew up watching how a carefully fitted joint lasts for decades. Code is the same: six near-identical-but-not-quite implementations look convenient and are actually the most fragile state you can be in. I decided to start by running a single structure through all of them.

What I did to unify them

The approach was simple: instead of sharing the screens themselves, I passed the contents of onboarding as configuration. Each app declares only its palette, copy, and step count; the rendering logic and the timing of the push-permission call live in one shared component.

// onboarding.config.ts —— the only thing that changes per app
export type OnboardingStep = {
  key: string;
  title: string;
  body: string;
  image: number; // the return value of require()
  askPushAfter?: boolean; // ask for permission right after this screen?
};
 
export const onboardingConfig: OnboardingStep[] = [
  {
    key: "welcome",
    title: "A quiet wallpaper for every day",
    body: "Dress your screen in a mood-matched wallpaper in a few taps.",
    image: require("./assets/onb-welcome.webp"),
  },
  {
    key: "value",
    title: "Your favorites are one tap away",
    body: "Saved wallpapers gather at the top of the home screen.",
    image: require("./assets/onb-value.webp"),
    askPushAfter: true, // ask for notifications only after value lands
  },
];
// SharedOnboarding.tsx —— identical across all six apps
import { useState, useCallback } from "react";
import * as Notifications from "expo-notifications";
import { onboardingConfig } from "./onboarding.config";
 
export function SharedOnboarding({ onDone }: { onDone: () => void }) {
  const [index, setIndex] = useState(0);
  const step = onboardingConfig[index];
 
  const next = useCallback(async () => {
    if (step.askPushAfter) {
      // show the OS dialog only after the user understands the value
      await Notifications.requestPermissionsAsync();
    }
    if (index < onboardingConfig.length - 1) {
      setIndex((i) => i + 1);
    } else {
      onDone();
    }
  }, [index, step, onDone]);
 
  return <OnboardingView step={step} progress={(index + 1) / onboardingConfig.length} onNext={next} />;
}

The key is askPushAfter. I used to fire the permission dialog right at launch, but asking before any value has landed just earns a rejection. While unifying, I standardized on "right after the second screen, where the value is clear." Each app now only edits a config file, and the whole class of "forgot to fix the copy" bugs disappears structurally.

Three walls that were harder than expected

I assumed unification would glide along. In practice a few things snagged.

First, the image assets were named differently in every app. The shared component expects the return value of require(), so I had to rename each app's assets/ to a convention like onb-welcome.webp. It's unglamorous, but leaving it vague defeats the point of sharing.

Second, the first-launch check. A few of the six stored their "first run" flag under different key names in older code, and after unification I once shipped a bug where existing users saw onboarding again. I fixed it by adding a small migration that reads the old keys and moves them to the new one.

Third, changing the push-permission timing caused a double dialog in one app, where my own pre-permission screen collided with the OS prompt. I only caught that duplication because I had consolidated the permission call into a single place—which, in the end, made the code easier to read.

What one month of numbers showed

Averaged across my six apps, comparing the four weeks before and after, the change looked roughly like this. These are observations from my own apps; genre and country mix will shift them, so read them with that grain of salt.

Onboarding completion rose from about 71% to about 84%—trimming three screens down to two seems to have helped. First-day retention (D1) went from around 32% to around 38% on a six-app average. Push opt-in climbed from 19%, back when I asked at launch, to 27% after moving the request to right after the value screen. Having shipped apps solo since 2014, seeing a double-digit-percent swing from nothing but permission timing was, honestly, a real takeaway.

A note on measurement: completion is the onDone event reaching the final screen, and first-day retention is a new user reopening the next day. I kept the measurement code untouched across the change, because if you don't pin that down you can never tell later whether the product improved or the metric simply drifted. On the other hand, I saw no clear difference in purchases or day-7 retention this time. Onboarding only governs the first few minutes; after that, other factors take over. A month confirmed that obvious truth all over again.

What I want to work on next

Next, I plan to fold per-language copy into the config file. Right now Japanese and English live in separate files, but if I tuck the localized strings inside the step definitions, managing six apps times languages gets one notch easier. Alongside that, I want to prepare a few variants of the second screen's copy and test, one app at a time, how far opt-in can move.

If your onboarding has scattered across several apps the way mine had, I hope this gives you a starting point for tidying it up. Thank you for reading.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◇ App Dev2026-08-16
My chart broke on day one, not at scale
A line chart that vanished for anyone with only a few days of data. The cause was a zero-height Y axis turning coordinates into NaN. Here is the measured behavior and the small normalization layer that fixed it.
◇ App Dev2026-08-06
Deciding overlay text legibility at ingest time instead of on device — four metrics measured side by side
Moving the question of whether text stays readable over a wallpaper out of the device and into the content pipeline. Four candidate metrics measured across 240 images, including what downscaled judging actually computes.
◇ App Dev2026-10-15
When adjustsFontSizeToFit Shrinks Just One Row, Suspect the Box Height, Not the String
Only the 10th row of a list drops to 4pt on iOS, though the data and strings are fine. I read the Yoga rounding and font-fit sources, then counted how often the box comes up short in Node.