RORK LABJP
DEADLINE — Four days remain until Google Play requires Android 16 (API level 36). From August 31 it applies to new apps and to updates of existing ones alikeRULES — The submission rule and the visibility rule are separate. An app you have stopped updating still disappears for new users on newer devices if it targets below API 35EXTENSION — An extension keeps you shipping to all users until November 1, but the form lives in Play Console and has to be filed before the deadline passesEXPO — Expo SDK 57 moves React Native from 0.85 to 0.86 while React stays at 19.2, and 0.86 is intended to land without breaking changesHERMES — 57.0.9 updates React Native to 0.86.2 and clears the Hermes V1 memory regression from SDK 56, which shows up in apps importing reanimated or workletsPREBUILD — expo prebuild now clears and regenerates the native android and ios directories by default, so hand-edited native changes vanish unless you audit for them firstDEADLINE — Four days remain until Google Play requires Android 16 (API level 36). From August 31 it applies to new apps and to updates of existing ones alikeRULES — The submission rule and the visibility rule are separate. An app you have stopped updating still disappears for new users on newer devices if it targets below API 35EXTENSION — An extension keeps you shipping to all users until November 1, but the form lives in Play Console and has to be filed before the deadline passesEXPO — Expo SDK 57 moves React Native from 0.85 to 0.86 while React stays at 19.2, and 0.86 is intended to land without breaking changesHERMES — 57.0.9 updates React Native to 0.86.2 and clears the Hermes V1 memory regression from SDK 56, which shows up in apps importing reanimated or workletsPREBUILD — expo prebuild now clears and regenerates the native android and ios directories by default, so hand-edited native changes vanish unless you audit for them first
Articles/Business
Business/2026-04-02Advanced

Linting ASO Keywords Across Locales — Protecting Apple's 160-Character Search Budget

The Node linter I run against name, subtitle and keywords in every locale to protect Apple's 30/30/100 character budget — with real output and what it caught.

ASO27global expansion3App Store Connect12keyword strategylocalization3

Premium Article

One spring I opened the Japanese keyword field for a wallpaper app after a long gap and stopped short. Out of the 100 characters available, I was using 29. Two of the terms inside were words already sitting in the app name.

Nobody made a single bad decision. The field had been edited a little at a time over several years, and as locales piled up, there was no longer any way to see the whole picture at once. Checking sixteen keyword fields by eye was never a workable process to begin with.

From that day I stopped treating the keyword field as something you think about and started treating it as something you protect with a script. What follows is that script, and the reasoning that had accumulated around it beforehand.

The App Store gives you a 160-character search budget

Start with the constraint. The text that iOS actually indexes for search is narrower than most people assume.

FieldLimitIndexed for search
App name30 charactersYes
Subtitle30 charactersYes
Keywords100 charactersYes
Description4,000 charactersNot on iOS

One hundred and sixty characters, total. A term that does not appear in that budget will not surface in search no matter how many times it appears in the description. That single fact is why keyword-stuffing an iOS description accomplishes nothing.

Google Play works differently and does draw on description text, so reusing one piece of copy across both stores means one of them comes up empty.

The part that costs the most is this: words in the name and subtitle are indexed separately. Repeating them in the keyword field spends your 100 characters on coverage you already had.

There is a smaller tax too. A space after a comma counts as a character. Separate seven keywords with ", " instead of "," and six characters are gone — enough for another short term.

All three rules are stable enough to be worth enforcing mechanically.

A script that lints the budget

I keep localized metadata in a plain JSON file and check it against those rules with a small Node script. No dependencies.

#!/usr/bin/env node
// aso-keyword-lint.mjs — lint App Store Connect localized metadata
// against the name 30 / subtitle 30 / keywords 100 budget.
import { readFileSync } from 'node:fs';
 
const LIMITS = { name: 30, subtitle: 30, keywords: 100 };
 
// Match Apple's counting: treat surrogate pairs as one character
const len = (s) => [...(s ?? '')].length;
 
// Normalize for comparison: width, case, surrounding whitespace
const norm = (s) => (s ?? '').normalize('NFKC').toLowerCase().trim();
 
// Pull the already-indexed words out of name / subtitle
const wordsOf = (s) =>
  norm(s)
    .split(/[\s,、・/|+\-–—::()()]+/u)
    .filter((w) => w.length > 1);
 
function lintLocale(locale, meta) {
  const errors = [];
  const warnings = [];
 
  for (const field of ['name', 'subtitle', 'keywords']) {
    const n = len(meta[field]);
    if (n > LIMITS[field]) {
      errors.push(`${field}: ${n} chars (limit ${LIMITS[field]}, over by ${n - LIMITS[field]})`);
    }
  }
 
  const raw = meta.keywords ?? '';
 
  // 1) A space after a comma is billed one character at a time
  const spaceWaste = (raw.match(/,\s/g) ?? []).length;
  if (spaceWaste > 0) {
    errors.push(`keywords: ${spaceWaste} space(s) after commas (${spaceWaste} chars wasted)`);
  }
 
  // 2) Empty tokens, trailing comma
  const tokens = raw.split(',').map((t) => t.trim());
  const empties = tokens.filter((t) => t === '').length;
  if (empties > 0) errors.push(`keywords: ${empties} empty token(s) (double or trailing comma)`);
 
  const live = tokens.filter((t) => t !== '');
 
  // 3) Duplicates inside the keyword field
  const seen = new Map();
  for (const t of live) {
    const k = norm(t);
    seen.set(k, (seen.get(k) ?? 0) + 1);
  }
  const dupes = [...seen.entries()].filter(([, c]) => c > 1).map(([k]) => k);
  if (dupes.length) errors.push(`keywords: duplicate token(s) ${dupes.join(' / ')}`);
 
  // 4) Terms already in name / subtitle — paying twice for the same coverage
  const indexed = new Set([...wordsOf(meta.name), ...wordsOf(meta.subtitle)]);
  const redundant = live.filter((t) => indexed.has(norm(t)));
  if (redundant.length) {
    const saved = redundant.reduce((a, t) => a + len(t) + 1, 0);
    errors.push(`keywords: already in name/subtitle ${redundant.join(' / ')} (~${saved} chars recoverable)`);
  }
 
  // 5) Unused budget
  const used = len(raw);
  const left = LIMITS.keywords - used;
  if (left >= 15) {
    warnings.push(`keywords: ${left} chars unused (${Math.round((left / LIMITS.keywords) * 100)}% of budget)`);
  }
 
  const note = `indexed ${len(meta.name)}+${len(meta.subtitle)}+${used} = ${len(meta.name) + len(meta.subtitle) + used} / 160 chars`;
  return { locale, errors, warnings, note };
}
 
const path = process.argv[2];
if (!path) {
  console.error('usage: node aso-keyword-lint.mjs <metadata.json>');
  process.exit(2);
}
 
const data = JSON.parse(readFileSync(path, 'utf8'));
let failed = 0;
 
for (const [locale, meta] of Object.entries(data)) {
  const r = lintLocale(locale, meta);
  const mark = r.errors.length ? 'NG' : r.warnings.length ? '--' : 'OK';
  console.log(`[${mark}] ${locale}  ${r.note}`);
  for (const e of r.errors) console.log(`     ERROR  ${e}`);
  for (const w of r.warnings) console.log(`     WARN   ${w}`);
  if (r.errors.length) failed += 1;
}
 
console.log(`\n${Object.keys(data).length} locales checked / ${failed} with errors`);
process.exit(failed > 0 ? 1 : 0);

The input is a flat JSON object keyed by locale. Whether you copy it out of the App Store Connect UI or pull it from the API, it lands in the same shape.

{
  "en-US": {
    "name": "Beautiful HD Wallpapers",
    "subtitle": "4K backgrounds for your phone",
    "keywords": "aesthetic, live wallpaper, wallpapers, lock screen, 4k, nature, minimal"
  },
  "ja": {
    "name": "壁紙アプリ HD",
    "subtitle": "高画質の背景画像を毎日お届け",
    "keywords": "壁紙,無料,おしゃれ,ロック画面,和風,自然,高画質,壁紙"
  },
  "zh-Hans": {
    "name": "高清壁纸",
    "subtitle": "每日更新的手机背景图片",
    "keywords": "免费壁纸,动态壁纸,锁屏,无广告,风景"
  },
  "ar": {
    "name": "خلفيات جميلة",
    "subtitle": "خلفيات عالية الدقة لهاتفك",
    "keywords": "خلفيات,مجاني,شاشة القفل,طبيعة,"
  }
}

That sample contains four mistakes I have actually shipped: spaces after commas plus double-indexing in English, a repeated token in Japanese, a trailing comma in Arabic. Only the Chinese locale is clean.

Here is the output on Node v22.23.2.

$ node aso-keyword-lint.mjs metadata.sample.json
[NG] en-US  indexed 23+29+71 = 123 / 160 chars
     ERROR  keywords: 6 space(s) after commas (6 chars wasted)
     ERROR  keywords: already in name/subtitle wallpapers / 4k (~14 chars recoverable)
     WARN   keywords: 29 chars unused (29% of budget)
[NG] ja  indexed 8+14+29 = 51 / 160 chars
     ERROR  keywords: duplicate token(s) 壁紙
     WARN   keywords: 71 chars unused (71% of budget)
[--] zh-Hans  indexed 4+11+19 = 34 / 160 chars
     WARN   keywords: 81 chars unused (81% of budget)
[NG] ar  indexed 12+25+30 = 67 / 160 chars
     ERROR  keywords: 1 empty token(s) (double or trailing comma)
     ERROR  keywords: already in name/subtitle خلفيات (~7 chars recoverable)
     WARN   keywords: 70 chars unused (70% of budget)
 
4 locales checked / 3 with errors
$ echo $?
1

In the English locale, six characters of whitespace plus fourteen characters of double-indexing means twenty characters are recoverable. Against a hundred-character field, that is not a rounding error.

Because it exits non-zero, dropping it in front of a metadata deployment job turns it into a gate rather than a report.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A dependency-free Node linter that checks the 30/30/100 character budget across every locale, catching post-comma spaces, duplicate tokens, and terms already indexed in the app name
Why the first implementation silently passed Japanese and Chinese locales, and the containment check that fixed it
How direct translation of English keywords failed, and how to split the keyword budget into evergreen and seasonal slots
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
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 $15 for lifetime access
View Membership →

Related Articles

Business2026-05-04
Building Japan-Market Apps with Rork Max: LINE Login, PayPay, and Japanese UX Optimization
A complete guide to succeeding in Japan's app market with Rork Max. Covers LINE login, PayPay payment integration, Japanese UX design principles, and App Store ASO strategies that actually move the needle for indie developers.
Business2026-07-19
Handing Over an App — What an App Store Transfer Carries, and What You Rebuild
A practical look at App Store Connect app transfers when selling or moving an app: the eligibility criteria, what carries over (ratings, subscribers, iCloud data), what you rebuild (TestFlight, APNs, merchant IDs), and the three places users actually feel the change.
Business2026-06-15
Managing Store Metadata as Code with the App Store Connect API — Turning Manual Edits into a Monthly System
As the apps you ship with Rork pile up, the time spent hand-editing store descriptions and prices stops being negligible. This walks through managing metadata as code with the App Store Connect API and rolling it out across a dozen apps, including the authentication pitfalls.
📚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 →