On 12 October I finished uploading the listing for my journal app Lanternly in 39 languages. Counted by fields, that's 819: name, subtitle and privacy policy link in App Information, six iOS version fields, five macOS version fields, and the subscription localizations (the group and three plans). I don't have a team of translators, and I don't have an ASO agency either.
This is how I put it together and where I tripped up. The limits were checked against the App Store Connect documentation on 13 October 2026.
First I decided what I couldn't lie about#
Lanternly's interface exists only in English and Russian. A listing in Swedish or Turkish sets the expectation that the app will be in Swedish or Turkish too. Someone downloads it, sees English, and leaves a one-star review or asks for a refund.
So in every language except English and Russian, the second line of the description says the same thing:
Die App-Oberfläche ist auf Englisch und Russisch.
Appens gränssnitt är på engelska och ryska.It comes right after the first paragraph, before the feature list. Further down, nobody would find it.
The second decision was about length. 14 languages got a full description of 3,000–4,000 characters, and 25 more got a short one of about 1,300 characters: what the app is, the honest line about the interface, the subscription block and the legal links. I didn't upload the 11 Indian languages at all. I have no way to check translation quality in Hindi or Tamil, and a bad translation on the listing is worse than the English original.
One file instead of 819 form fields#
Editing 39 languages directly in the App Store Connect form doesn't work: you fix the subscription wording in English and then have to remember the other 38 places it appears.
The source of truth moved into a single JSON file: locale → fields. The iOS and macOS descriptions differ by one final paragraph (the Mac has its own "where else it runs" list), so I store the shared text plus a Mac override. Every edit goes into the file first, passes the check, and only then goes to the store. Next to it there's a TSV with names, subtitles and keywords, which is easy to eyeball and easy to check with a script.
The limits everything runs into#
| Field | Limit | Where it's set |
|---|---|---|
| Name | 30 characters | App Information, shared by iOS and macOS |
| Subtitle | 30 characters | Same place |
| Keywords | 100 bytes per the documentation | Version, separate for each platform |
| Promotional Text | 170 characters | Version, changes without review |
| Description | 4000 characters | Version |
| What's New | 4000 characters | Version |
On keywords, the documentation says: "You can provide up to 100 bytes of content". Bytes, not characters. In UTF-8, Cyrillic takes two bytes per letter and Japanese takes three. My Russian keywords are 93 characters, which is 176 bytes, and App Store Connect accepted them without complaint. In practice it counts characters, but I wouldn't count on that never changing.
The tightest spots weren't where I expected. Name and subtitle fit in 30 characters everywhere: German compound words are long, but there are few of them. What I actually ran into:
- German promotional text came out at exactly 170 of 170. Not even one more comma fits.
- Dutch promotional text had to be shortened: the first version didn't fit.
- The French description didn't fit in 4000. What saved it was that the privacy and terms links appeared twice, once in the text and once in the required block at the bottom. I removed the duplicate and got 3917.
A script that checks what the form can't see#
App Store Connect only checks length. It quietly accepts everything else, and the only way you'd notice a mistake is missing impressions. So before every upload I run a small script over the TSV. Here's a simplified working version:
#!/usr/bin/env python3
"""Check name/subtitle/keywords per locale: python3 check_fields.py fields.tsv"""
import csv, re, sys
LIMITS = {"name": 30, "subtitle": 30, "keywords": 100}
BANNED = {"app", "free", "apple", "iphone"}
def words(s):
return re.findall(r"[\w’'-]+", s.lower())
bad = 0
for r in csv.DictReader(open(sys.argv[1], encoding="utf-8"), delimiter="\t"):
issues = [f"{f}: {len(r[f])}/{n}" for f, n in LIMITS.items() if len(r[f]) > n]
kw = r["keywords"]
if re.search(r",\s", kw):
issues.append("space after comma")
kw_words = [w for t in kw.split(",") for w in words(t)]
dup = {w for w in kw_words if kw_words.count(w) > 1}
if dup:
issues.append(f"duplicate in keywords: {sorted(dup)}")
title = set(words(r["name"] + " " + r["subtitle"]))
if set(kw_words) & title:
issues.append(f"duplicate of name/subtitle: {sorted(set(kw_words) & title)}")
if (set(kw_words) | title) & BANNED:
issues.append(f"unwanted words: {sorted((set(kw_words) | title) & BANNED)}")
bad += bool(issues)
print(("FAIL " if issues else "OK ") + r["locale"], *issues, sep="\n - ")
sys.exit(1 if bad else 0)The input is a TSV with locale, name, subtitle and keywords columns. What it catches and why:
- A space after a comma. That's simply a wasted character out of a hundred. Apple separates keywords with commas and has no use for the space.
- A word repeated within the keywords, or repeated from the name or subtitle. Words in the name and subtitle already count for search, so repeating them in keywords spends the space twice.
- Words you don't need.
appandfreeadd nothing, and the documentation explicitly forbids names of other apps and companies. My full list is longer: it also includes words I don't use anywhere in the product, such as medical labels.
A second script checks the length of descriptions and promotional texts, and I save its output to a file next to the JSON. A month later, at the next edit, I can see right away which language has no room left.
How I translated#
I drafted the translations with a language model. No native speakers proofread them. There's no point hiding it: this is exactly what a solo developer can afford. So I held myself to a few rules.
Translate the meaning, not the phrases. The English promotional line "Free to write as much as you like" became "Schreib, so viel du willst" in German: the word for "free" wouldn't have fit within the limit, and the meaning survived.
Write keywords the way people type them. Russian speakers search for «дневник эмоций» ("emotions journal") and «дневник с паролем» ("journal with a password"), so the keywords contain эмоций and паролем, the genitive and instrumental case forms of those words rather than the dictionary forms. Germans search with compound words, so dankbarkeitstagebuch goes in as one whole word. In Polish, dziennik more often means a school's electronic gradebook, so the main word in the name is Pamiętnik.
Short descriptions for languages I can't check. The shorter the text, the fewer places for an odd phrase I wouldn't notice.
Links need localizing too#
The links have to be localized as well. Each locale has its own Privacy Policy URL, Support URL and Marketing URL, and every description ends with a block of legal links. The documents on the website exist in five languages, so Spanish locales point to /es/, Japanese to /ja/, Chinese to /zh-CN/, and the rest to /en/. Support is available in English and Russian. I wrote a separate article about the EULA block itself and why a subscription app needs it.
Uploading: scripts only#
Typing 819 fields by hand means losing a day and almost certainly making a typo somewhere. I uploaded with a script running in my own App Store Connect session. If you do this regularly, the proper route is the official App Store Connect API: it has appInfoLocalizations, appStoreVersionLocalizations, subscriptionGroupLocalizations and subscriptionLocalizations resources, which covers everything I listed above.
Two things I ran into along the way:
- The first Mac version has no What's New field. macOS 1.1 is Lanternly's first Mac version, so the field simply doesn't exist for it, and the script has to skip it.
- Name and subtitle are shared by iOS and macOS, while keywords and description are separate for each platform. I copy them over one to one; only the last paragraph of the description changes.
What's still not done#
Screenshots in all the new languages are still in English. The interface is English or Russian anyway, so that's honest, but not ideal. No native speaker has read the texts in 37 languages. If you speak Swedish, Turkish or Polish and find a mistake in Lanternly's listing, I'd be glad to get your email.
If I were starting over, I'd set up the single source file and the checker script on day one, before the first translation. The rest of this work repeats every time, and a script can handle it.



