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.