Onboarding That Drops Users Into a Working App, Not an Empty Shell
A Walk Through the Real Flow
The onboarding is eight steps on a progress bar, and most of them take seconds. The screens live in app/onboarding/ in the Expo Router app. Let me walk you through what each one does, because the interesting decisions hide in small places.
The welcome screen is a theme picker. Not a login form and not a permissions wall. The headline reads “Money, without the spreadsheet.” and below it sit five theme chips: Light, Dark, Digital Nomad, Digital Nomad 2077, and Immigrant.
The detail I care about most: above the chips there’s a mock category card, a “Grocery” card showing $342 spent with a progress bar at 55% and “$158 left” under it. It renders with the live theme tokens, so every chip you tap restyles it instantly. Your first interaction with the app is play, not data entry. And the card quietly teaches you what the core screen will look like before you’ve answered a single question.
Three story cards. Steps two through four are one-liner narrative screens, like “Every dollar has a job.” They state the method in plain words and get out of the way fast.
About you. Step five asks for a first name, a main currency from a short list (USD, EUR, GBP, TRY, AMD, JPY), and optionally extra currencies to also track. The Next button stays disabled until the name is non-empty, and the extras list excludes whatever you picked as the base. That’s the entire form. No email field, because there’s no account to attach it to.
The tiles step. This is the one that does the heavy lifting. “What do you want to track?” with six multi-select tiles: everyday spending, home and bills, transport, travel fund, long-term savings, and a health check. Behind them sits a declarative catalogue:
// packages/common/src/providers/onboarding/tile-catalogue.ts (trimmed)
export const TILES: Record = {
everyday: {
id: 'everyday',
label: 'Everyday spending',
blurb: 'Groceries, cafe, personal, shopping',
target: { kind: 'default-group', section: 'expenses' },
categories: [
{ id: 1001, name: 'Grocery', monthlyAvg: 600 },
{ id: 1002, name: 'Cafe', monthlyAvg: 320 },
{ id: 1004, name: 'Personal', monthlyAvg: 80 },
{ id: 1005, name: 'Shopping', monthlyAvg: 100 },
],
},
travel: {
id: 'travel',
label: 'Travel fund',
blurb: 'Rolls over month to month so you can save for trips',
target: {
kind: 'new-group',
groupId: 'travel',
groupSettings: { isRollingBalance: true /* ... */ },
},
categories: [
{ id: 1030, name: 'Travel Fund', monthlyAvg: 0, section: 'savings' },
{ id: 1031, name: 'Trip expenses', monthlyAvg: 0, section: 'expenses' },
],
followup: { kind: 'travel-yearly' },
},
health: {
id: 'health',
label: 'Health check',
blurb: 'Will I make it to month end? Adds the Health tab',
target: { kind: 'feature-flag', flag: 'showHealth' },
categories: [],
followup: { kind: 'health-income' },
},
// home-bills, transport, long-savings follow the same shape
};
A few things worth noting here.
Each tile declares a target, and there are three kinds. A default-group tile pours its categories into the standard Budget group. A new-group tile creates a whole separate account group, so picking “Travel fund” gives you a dedicated Travel tab with a rolling balance that carries over month to month. And a feature-flag tile creates nothing at all: the health check just flips showHealth on, which adds the Health tab to the app.
The monthlyAvg numbers are priors. They become the default budget for each category, and, as you’ll see in a minute, they drive the sample history too. One catalogue entry drives several downstream effects, and the screens never special-case any of it.
Conditional followups. The flow only asks extra questions when a picked tile declares one:
// apps/mobile/app/onboarding/tiles.tsx
function handleNext() {
const needsFollowup = picks.some((id) => TILES[id].followup !== undefined);
router.push(
needsFollowup ? '/onboarding/followups' : '/onboarding/summary',
);
}
There are exactly two. Pick the travel tile and it asks how much you usually spend on travel per year, then divides that by twelve to set a monthly contribution target on your Travel Fund. Pick the health check and it asks for your monthly income after tax, which becomes a recurring income entry that the Health tab uses to compute your spending pace. Skip both tiles and you never see this screen.
Summary and finish. The last step recaps your picks under “Your plan is ready” with your name, and a single button labeled “Open Noma” hands everything to one function:
// apps/mobile/app/onboarding/summary.tsx (trimmed)
async function handleFinish() {
await onboarding.finish({ picks, answers, mode });
await refreshGroups();
router.replace('/');
}
Under the hood, finish calls applyOnboarding, which is a pure function: current config, picks, and answers go in, and a new config plus a list of transactions and incomes come out. Nothing touches storage until that result is persisted, which makes the whole flow testable without rendering a single screen.
Then you land on the home screen, and it’s already alive.