←Yunus Emre Alpak

I Searched for My Own App and It Wasn't There — Being Findable on the App Store

2026 · 08 · 2210 min read

When the approval mail arrived, I thought the job was done. I opened my phone and searched for "tessera" — nothing. That was the moment it landed: being approved and being findable are two different jobs. This is the story of shipping a product I built with care for six months, and leaving its store page to guesswork — and what I learned rebuilding it from data.

A magnifying glass finding a single mosaic-tiled app icon among a shelf of identical grey ones

Approved. Live. Invisible.

Tessera went live on the App Store on 20 August. The approval mail arrived, all three products were APPROVED, the version was READY_FOR_SALE. One thing left to do: open my phone and search for my own app.

I typed "tessera". It wasn't there.

Twenty-two results came back — a passport photo app, a burger chain, a Latin devotional card app. Mine wasn't among them. I later learned this was the indexing lag every new app goes through; it resolved a few days later. But in that moment it made something clear: being approved and being findable are two different jobs. I had finished the first. I had never started the second.

On top of that: zero ratings, zero reviews.

The two phases of search

App Store search runs in two stages, and that split decides everything.

Phase 1 — indexing. Apple works out which apps are candidates for a query from your text fields: title, subtitle, keyword field. For a word that appears in none of them, you don't compete at all.

Phase 2 — ranking. Among the candidates, download volume and ratings decide the order.

There is nothing a new app can do about phase 2. I had zero downloads and zero ratings; on a category-defining term like "habit tracker" I was never going to outrank Productive and its 91,000 ratings. But phase 1 is entirely mine to control: I decide which queries I'm a candidate for. And until that day, I had been making that decision by guessing.

Giving up on guessing

When I first wrote the metadata, I picked words because they sounded right. This time I ran three research tracks in parallel: a competitor sweep, English keyword and algorithm mechanics, and the Turkish market. All of it against live data — the iTunes Search API is public and free:

curl -s "https://itunes.apple.com/search?term=habit+tracker&country=us&entity=software&limit=15"

Three findings changed the work.

The "Tessera" brand term is uncontested. Not one habit app among those twenty-two results, and every app actually named "Tessera" sits between 0 and 23 ratings. On my own brand term I compete with nobody.

Every mosaic rival is dead. "Habit Mosaic", "Mosaic: Habit Tracker", "HabitPuzzle Mosaic" — all of them at zero ratings. More interesting: all of them frame a missed day as blank or broken. Tessera's kintsugi angle — a missed day mended in gold rather than erased — is completely unclaimed in the category. Its nearest neighbour is (Not Boring) Habits at 6,000 ratings, and even that one says it only in the description, never in the title.

I had missed the title formula. HabitKit's developer published his ASO strategy: ~98% of his installs come from store search, and the formula is putting the "habit tracker" bigram in the title. My title had "Habit" but not "Tracker". I was carrying half the category term in the heaviest field there is.

Three fields, one query

How title, subtitle and keyword field are weighted and combine into a query

Here is the conceptual jump: the fields are not indexed separately, they combine with each other.

If "daily" is in your subtitle and "habit tracker" is in your title, you're indexed for "daily habit tracker" — even though that exact phrase is written nowhere. That turns a tight budget of 30 + 30 + 100 characters into something else entirely: you stop planning words and start planning combinations.

The practical rules that fall out of it:

That last rule hit my old metadata directly. My Turkish subtitle was "Sanata dönüşen alışkanlık" — but "Alışkanlık" was already in the title, so my second most valuable field was earning me exactly one new word. It became "Günlük seri ve rutin takibi": four new words, and the head query "alışkanlık takibi" now assembles from the title and subtitle together.

The storefronts' hidden language map

A map of which second language each App Store storefront indexes

This was the most surprising part of the research. An App Store storefront does not index only its own language — it reads a second one too.

The US storefront indexes es-MX alongside en-US. Which means the keyword field of your Mexican Spanish localization is an extra 100 characters in the United States. Apple does not validate that field's language; put different (non-repeating) words there and they count in US search too.

The Türkiye storefront reads tr plus en-GB — not en-US. That one was a straight error on my part: if I want to reach someone searching "habit tracker" on the Turkish storefront, having those English words in en-US does nothing at all. Without opening an en-GB localization, that traffic was simply unreachable.

There's also a trap specific to Turkish: App Store search does not normalize Turkish diacritics. "alışkanlık takip" and "aliskanlik takip" return different result sets. You need to cover both, but you can't write both in the title — so the accented form lives in the title and the bare aliskanlik sits in the keyword field.

Building the package, and verifying it

What came out was a four-locale package: en-US and tr updated, with en-GB (for English storefronts outside the US, and for Türkiye) and es-MX (both a real Spanish listing and extra characters in the US) opened from scratch.

FieldBeforeAfter
en-US titleTessera: Habit MosaicTessera: Habit Mosaic Tracker
en-US subtitleHabit tracker that makes artDaily streaks that become art
tr subtitleSanata dönüşen alışkanlıkGünlük seri ve rutin takibi
Locales24

But while hand-managing that many fields, how do you stay sure no word repeats across two of them? You don't — so I wrote a script. The critical check is the intersection between sets that are co-indexed on the same storefront:

PAIRS = [("US", "en-US", "es-MX"), ("MX", "es-MX", "en-GB"), ("TR", "tr", "en-GB")]
for store, a, b in PAIRS:
    dup = keywords(a) & keywords(b)
    print(f"{store}: {a} ∩ {b} = {dup if dup else 'clean'}")

The same script checks character limits and each locale's own title/subtitle overlap. The output reads in one line: ALL OK. Because I manage metadata as files through fastlane (I wrote about that earlier), this verification is part of the commit rather than an eyeball check in a web panel.

The real work starts where metadata ends

Keywords solve phase 1. Getting the person who lands on the page to actually install is a separate job, and I had two gaps there.

The screenshots didn't say a word

The raw simulator capture next to the captioned store frame

My six store frames were bare simulator captures. Meanwhile roughly 70% of visitors never scroll past the first two, and captioned frames lift conversion by 20-35%.

I reordered them — the artwork leads now ("Your habits become art"), the gold mend follows ("Missed days heal in gold") — and set a line above each one in the app's own typeface. Rather than doing that by hand in Figma, I wired it into the pipeline: capture.sh writes the raw frames, compose.py sets the headlines and produces the store set. When a screen changes in the next release, the whole set regenerates with one command.

The app never asked for a rating

You can't do anything in phase 2 with zero ratings, and Tessera had never asked for one. But building that feature, the real question wasn't "how" — it was "when".

Tessera has a ceremony: when a piece is finished, the mosaic re-weaves itself and takes its name. That is the moment the user is proudest of what they made. The rating ask went there.

One problem: the paywall offer was wired to that same moment. Two sheets back to back devalue each other. The fix was making the offer report its own outcome:

final offered = await getIt<PaywallPresenter>().offer(context, request);

// The rating ask takes the same beat only if the offer stayed quiet — one sheet per moment.
if (!offered && mounted) {
  await getIt<ReviewPromptService>().onCeremonyShown();
}

The service itself follows three rules: one ask per install, a fallback trigger for users who haven't finished a piece yet (the 7th completion), and the guard written before the request — so that a crash while the sheet is up can't buy the app a second attempt.

Future<void> _maybeAsk() async {
  if (await _settings.read(AppStateKeys.reviewAskedOn) != null) return;
  try {
    if (!await _review.isAvailable()) return;
    await _settings.write(AppStateKeys.reviewAskedOn, formatOffsetTimestamp(_clock.now()));
    await _review.requestReview();
  } on Exception {
    // A rating ask must never surface as an error.
  }
}

Widget completions deliberately don't feed that counter: the sheet can only render while the app is in the foreground, so only in-app moments ask.

What can change when

A practical distinction, because it shapes the plan:

FieldWhat it needs
Promotional textNothing — changes on the live version instantly
Title, subtitle, keywordsA new version and a review
Screenshots, descriptionA new version

So an ASO change isn't a metadata edit, it's a release. And if you need a new build anyway, it makes sense to put code work like the rating flow in the same one — which is what I did. 1.0.1 (build 5) shipped as a single package: four-locale metadata, captioned screenshots, the rating ask.

What's left

I don't know the results yet. The version is in review, rankings will settle over a few weeks, and this post may never get a sequel that says "it worked". But what I took from the process was independent of the outcome anyway.

I built the product with care for six months: the constants of the mosaic geometry, where the gold seam falls, which moment is deliberately not a sales moment. Then I dropped that product into the store with twelve words that sounded nice. Every decision inside the app rested on data and intent; the path by which the app gets found rested on a guess.

A store page isn't a marketing surface sitting outside the product — it's the first screen the user sees. You owe it as much engineering as the rest.