◉RORK LABJP
●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — The stable release was still unpublished on its Oct 12 target. rc.4 (Oct 7) is the newest; 0.87.1 is Latest●Q&A — Readers report expo-widgets showing a black widget in production builds only●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — Three checks to run first when notifications stop arriving in Expo Go●EXPO — EAS Observe now records native crashes (Oct 7). On SDK 57, update to 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) are now in the model menu●RN 0.88 — The stable release was still unpublished on its Oct 12 target. rc.4 (Oct 7) is the newest; 0.87.1 is Latest●Q&A — Readers report expo-widgets showing a black widget in production builds only●WINDOWS — A walkthrough shows how to ship an iPhone app from Windows only, with no Mac●NEW — Three checks to run first when notifications stop arriving in Expo Go
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 Connect15keyword 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-10-08
Before You Build a Client's App in Rork, Decide Who Owns the Store Account
When you hand a Rork-built app to someone else, the first decision is which developer account publishes it. A walk through Apple's and Google's official transfer rules: what must be true before a transfer, what carries over, what disappears, plus a small script that lists the settings in your project tied to your own name.
⟐ Business2026-10-01
Multiseat Purchases Are Already On: Decide Whether to Keep Them Before October 22
Multiseat purchases for subscriptions have been on by default in App Store Connect since September 16. Before Volume Purchasing starts on October 22, here is how an indie developer can decide, in four questions, whether to keep them.
⟐ Business2026-09-08
A Share Button Is Not a Social Media Capability. A Feed Makes 13+ Your Floor
The App Store age rating questionnaire now asks whether your app has social media capabilities, and answers become required in September 2026. Here is where Apple drew the line, why the under-13 escape hatch obliges you to read age ranges, and how that looks in an Expo app.
📚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