GEMINI LABJP
SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_contentCHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoffCLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 modelsCHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-LiteSUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_contentCHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoffCLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 modelsCHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite
Articles/API / SDK
API / SDK/2026-05-31Intermediate

Localizing App Store Keyword Fields with Gemini 2.5 Flash — A Month of Notes Across 40 Apps

Operational notes from a month of using Gemini 2.5 Flash to draft the 100-character App Store keyword field across 40 wallpaper apps and several locales — CJK character counting, deduping against the title, prohibited terms, per-locale quirks, and what actually moved the needle.

gemini-api279gemini-flash6aso4indie-dev44ios12localization4

Opening App Store Connect and finding the Spanish keyword field still filled with English is what started this whole exercise.

I could not hand-rewrite a 100-character field 40 times over

The App Store Connect keyword field gives you exactly 100 characters per locale. It is only 100 characters, but what you put there visibly changes organic discovery. The problem was that I run roughly 40 wallpaper apps, and giving each one keywords in Japanese, English, Spanish, German, Portuguese, and Traditional Chinese means filling 40 × 6 = 240 slots by hand.

For years I honestly filled only the Japanese and English locales and left the rest empty or reused English — even though more than half of my revenue comes from users outside Japan. At an optimistic fifteen minutes per slot, that is sixty hours of work. Never having a free block of sixty hours was the excuse I used to postpone it, year after year.

So at the end of April 2026 I wired Gemini 2.5 Flash into the process to draft the keywords. This is a log of the first month — not a finished optimum, but the places I stumbled, left as they were.

Why Flash rather than Pro — counting the tokens for 240 slots

I tried Gemini 2.5 Pro first, and it was plainly overkill for keyword generation. Each slot takes about 200 tokens of input — the app name, category, and a few bullet-point features — and the output is under 100 characters. Cycling through 240 slots cheaply and quickly matters far more than reasoning depth.

In my measurements Flash answered in about a second per slot, against three to five seconds for Pro. Run as an overnight batch over 240 slots, that difference is not negligible.

Cost stopped being a question once I counted the scale instead of reading the rate card. At roughly 200 input and 100 output tokens per slot, one full pass over 240 slots is 48,000 input and 24,000 output tokens. Even three passes while tuning the prompt lands at about 144,000 input and 72,000 output tokens. At Flash-tier pricing that is pocket change, and I could not find a reason to reach for Pro.

DimensionGemini 2.5 ProGemini 2.5 Flash
Response per slot (measured)3–5 s~1 s
240-slot batch (estimated)15–20 min4–5 min
Tokens across three tuning passes~144,000 in / ~72,000 out
Quality verdictExcessiveSufficient with schema + validation

Not over-speccing the model relative to the difficulty of the task is one of the disciplines I hold to in production. Counting how many tokens will actually flow turned out to be a faster route to a decision than comparing per-million rates.

Receive keywords as structured output, not free text

My first mistake was asking the model in the prompt for "comma-separated keywords under 100 characters" and accepting free-form text. Flash returns keywords obediently, but it occasionally adds an explanation, slips in punctuation, or runs a few characters over. Across 240 slots, cleaning up output that is "almost right but not machine-parsable" becomes its own nightmare.

So I pinned the type with responseSchema and received the keywords as an array. I do not ask the model to control the length; I pack it myself after receiving it.

from google import genai
from google.genai import types
 
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
 
def draft_keywords(app_name: str, features: list[str], locale: str) -> list[str]:
    prompt = (
        f"Suggest search terms for an App Store keyword field.\n"
        f"App name: {app_name}\n"
        f"Features: {', '.join(features)}\n"
        f"Locale: {locale}\n"
        f"- Use terms users in {locale} would actually search\n"
        f"- Do not include words already in the app name or category\n"
        f"- 2-12 characters each, 20 terms max"
    )
    res = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema={
                "type": "array",
                "items": {"type": "string"},
            },
            temperature=0.4,
        ),
    )
    return [w.strip() for w in res.parsed if w.strip()]

With an array, the downstream "dedupe → length check → pack to 100 characters" runs as deterministic code. The trick was to leave none of the generation's uncertainty inside the model and instead receive it as editable material.

I settled on temperature=0.4 because pushing toward 0 froze the vocabulary and returned the same handful of terms every run, while 0.8 started producing invented compounds no one in that locale would type. When you generate twenty candidates and trim afterwards, the middle of that range is the easiest to work with.

Let your code, not the model, finalize the length

The keyword field is labeled "100 characters," but for Japanese and Traditional Chinese it is effectively counted by character, while leaving length control to Gemini produced mismatches between a len()-based count and per-locale full-width handling — and my build-time validation caught them again and again.

In the end I unified on letting my own code, not the model, finalize the length. I expect Gemini only to "over-produce candidates," and I pack toward 100 characters deterministically, in priority order.

def pack_keywords(words: list[str], limit: int = 100) -> str:
    seen, packed, length = set(), [], 0
    for w in words:
        key = w.lower()
        if key in seen:
            continue
        add = len(w) + (1 if packed else 0)  # account for the comma
        if length + add > limit:
            continue
        packed.append(w)
        seen.add(key)
        length += add
    return ",".join(packed)

The continue matters more than it looks. With break, a single long term arriving mid-list throws away every shorter term behind it. Skipping past what does not fit and trying the next candidate reliably slipped one extra short term into the leftover three to five characters.

Apple's help says keywords are comma-separated with no spaces needed, and adding spaces naively wastes precious characters. Gemini's output sometimes contains spaces too, so I strip them at the packing stage. A small detail, but in a world of only 100 characters, every character counts.

Deduping against the title and subtitle mattered most

The biggest ASO gain came not from clever keyword generation but from the dull work of deduplication. The App Store indexes the app name, subtitle, and keyword field together. If your title already contains "wallpaper," repeating "wallpaper" in the keyword field wastes part of your 100 characters.

So I tokenized each app's title and subtitle into an exclusion list, handed it to Gemini as "please don't use these," and then filtered again on my side after generation. I guarded twice because instructions alone still let about 20% of excluded words slip back in. Instruction and validation work best as a pair — that lesson kept reasserting itself.

This deduplication alone freed up space for higher-volume terms, recovering on average three to four words' worth of room per app.

Folding blocked terms, duplicates, and length into one validation gate

Another pitfall is slipping in a competitor's app name or trademark. Gemini is obliging, so when a feature line carried a phrase like "popular X-style," it would sometimes propose another company's brand as a search term. That invites review rejection and legal risk.

I originally scattered these checks across several places and lost track of where a term had been dropped. Collecting them into one function that rejects with a reason fixed that.

BLOCKED = {"instagram", "pinterest", "photoshop"}  # brands and trademarks
 
def validate(words: list[str], excluded: set[str], locale: str) -> tuple[list[str], list[str]]:
    kept, rejected = [], []
    for w in words:
        key = w.lower().strip()
        if not key:
            rejected.append(f"{w}: empty")
        elif key in BLOCKED:
            rejected.append(f"{w}: on blocklist")
        elif key in excluded:
            rejected.append(f"{w}: duplicates title/subtitle")
        elif " " in w:
            rejected.append(f"{w}: contains a space")
        elif len(w) > 20:
            rejected.append(f"{w}: too long ({len(w)} chars)")
        else:
            kept.append(w)
    return kept, rejected

Returning rejected alongside the survivors turned out to be the single most useful decision operationally. A full run over 240 slots drops close to a hundred terms, but each one carries its reason, so a sudden spike in blocklist hits is visible immediately. Back when rejections were discarded silently, I could not see what a prompt change had done.

This is an area you must not leave to the model's cleverness. Where a legal or policy judgment is required, I always build the last line of defense in deterministic code. Letting AI draft is efficient, but final responsibility stays with the operator.

Each locale behaved differently in ways the docs do not mention

Running all 240 slots at once made one thing clear: the same prompt produces results with different characteristics depending on the locale. This is the part you only see by actually pushing traffic through.

LocaleWhat happenedHow I handled it
GermanCompound nouns run long — a single term can eat 15+ charactersCap term length and ask for split alternatives alongside compounds
Traditional ChineseHigh information density per character; 100 characters holds a lotRaised the candidate count from 20 to 30 to fill the space
Spanish / PortugueseAccented and unaccented spellings both get typedKeep both as candidates and treat them as distinct in dedup
JapaneseHiragana, katakana, and kanji spellings all appearFavor katakana to match what people actually search

German was the worst offender: some slots fit only three terms in the full 100 characters. Asking for shorter split forms alongside the compounds brought that back up to six or seven. I considered branching the prompt per locale, but to avoid maintaining separate templates I made only the term-length cap a per-locale parameter.

What stayed with me after a month — including what did not work

Honestly, there is no flashy story of revenue jumping a month after changing keywords. In a category as crowded as wallpapers, a month of impression movement is hard to separate from seasonality and featuring effects. Still, the apps where I replaced reused-English fields with native-language keywords showed a gentle upward trend in search-driven impressions.

Some things did not work. Early on I asked the prompt to "order terms by search volume," but the model has no access to real volume data, so it simply returned a plausible-looking order. A ranking with no numbers behind it is worse than no ranking, so I removed the instruction. What you can reasonably ask the model for is coverage of candidates, not prioritization among them.

What mattered most was that 240 slots — work I would otherwise never have touched by hand — became something I could run with realistic effort. Gemini drafts, my code packs and validates, and I confirm with my own eyes at the end. With that division of labor in place, the psychological barrier to adding a locale all but vanished.

More than the technology itself, I value that it turned a job I had left undone into a job I could actually do. Next, I want to pull in real per-locale search-volume data and automate the prioritization of terms as well. If your own multi-locale rollout has stalled, start with a single app and a single locale, and just read the rejected list from validate. Seeing what gets thrown out tells you what to fix next.

Share

Thank You for Reading

Gemini 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

API / SDK2026-06-01
Mixing Gemini 2.5 Flash and Flash-Lite for App Store Localization
An operations log from running the same wallpaper-app store copy through both Gemini 2.5 Flash and Flash-Lite. Real cost gaps, where the lighter model breaks down, and how I now route by text type and locale.
API / SDK2026-05-30
Two Months of Turning App Store Connect Daily Sales into a Slack Digest with Gemini 2.5 Flash
Notes from two months of running App Store Connect Sales/Trends data through Gemini 2.5 Flash and posting a short morning digest to Slack. Why Flash beat Pro for this job, how AdMob and store revenue stopped colliding, and what a single 'normal/check' label changed.
API / SDK2026-05-25
Running In-App Help Translation on Gemini 2.5 Flash for Three Months — An Indie Developer's Notes
After three months running my iOS and Android in-app help through a Gemini 2.5 Flash translation pipeline, here are the operational notes — when to fall back to Pro, how glossaries help, and the small lift it added to AdMob revenue.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →