I noticed it while restructuring six wallpaper apps to build from a single codebase.
Two of the six had an android.package value that did not follow the naming rule the other four shared.
Those two were shipped back when I still thought I could tidy things up later. Tidying them up was no longer an option.
An app has three names: the one shown in the store, the slug you use during development, and the identifier that tells the platform which app this actually is. Only the first one can be changed after release.
Rork fills in that identifier for you at generation time. Something that runs lands in your hands almost immediately, so it is easy to carry the default all the way to submission. The moment you submit, though, that string is locked.
Here is what I now check in the thirty minutes before shipping a first build.
After release, only the display name is still yours to change
Splitting the store-facing fields into changeable and fixed looks like this.
| Field | App Store | Google Play | Changeable later |
|---|---|---|---|
| Display name | Change with a new version review | Change from the store listing | Yes |
| Icon and screenshots | Swapped per version | Swapped any time | Yes |
| Description and category | Change with review | Change any time | Yes |
| Bundle ID / package name | Locked once registered in App Store Connect | Locked once published | No |
| SKU | Set at registration | Not applicable | No |
| Store URL | A numeric ID assigned at registration | The package name appears directly in the URL | No |
The last row is the one worth pausing on. On Google Play, the package name shows up verbatim in the store URL. On Android, the identifier is not purely an internal concern — it is something people can see.
One detail that caught me out: the App Store bundle ID is locked when you register the app in App Store Connect, not when it goes live. If you have created the record but never submitted for review, it is already fixed.
Signing keys are a slightly different story — on Google Play you can request an upload key reset. If that is where you are stuck, How to fix the signing key error that blocks your Rork app upload to Google Play walks through the process.
The identifier Rork writes is often still a placeholder
A project exported from Rork has something like this in app.json (or app.config.ts).
{
"expo": {
"name": "wallpaper",
"slug": "wallpaper",
"ios": { "bundleIdentifier": "com.anonymous.wallpaper" },
"android": { "package": "com.anonymous.wallpaper" }
}
}Anything starting with com.anonymous. is an Expo default. It is a valid identifier, it builds, and it installs on a device. Because nothing breaks, there is no signal telling you to look at it. That is the part I find worth naming out loud: most mistakes in app development announce themselves with an error. This one waits quietly until the day you want to change it, which is the one day you cannot.
Replacing it with something of your own is not complicated.
- If you own a domain, reverse it and use that as the prefix (
dolice.netbecomesnet.dolice.) - If you don't, anchor it to an account that actually exists (
io.github.<username>., for instance) - End with a short word that names the product
What I would avoid is embedding anything whose meaning can drift. Suffixes like free, v2, 2026, or ios end up pointing at a decision you made years ago — the moment a free app gains a paid tier, or a rewrite lands, the identifier is describing a product that no longer exists. I did this once, and it shaped how I named everything that came after.
Check it mechanically, before you register
Reading these by eye stops being reliable somewhere around the sixth app. This is the script I run before submitting. There are no dependencies; Node alone is enough.
// check-app-ids.mjs
import { readFileSync, existsSync } from "node:fs";
const RESERVED = new Set(["abstract","assert","boolean","break","byte","case","catch",
"char","class","const","continue","default","do","double","else","enum","extends",
"final","finally","float","for","goto","if","implements","import","instanceof","int",
"interface","long","native","new","package","private","protected","public","return",
"short","static","strictfp","super","switch","synchronized","this","throw","throws",
"transient","try","void","volatile","while"]);
const PLACEHOLDER = [/^com\.anonymous\./i, /^host\.exp\./i, /^com\.example\./i,
/\byourname\b/i, /\byourcompany\b/i, /\bmyapp\b/i, /\btest\b/i];
function readConfig(path) {
if (!existsSync(path)) return null;
const raw = JSON.parse(readFileSync(path, "utf8"));
return raw.expo ?? raw;
}
function checkAndroidPackage(pkg) {
const errs = [];
if (!pkg) return ["android.package is not set"];
const segs = pkg.split(".");
if (segs.length < 2) errs.push("fewer than two dot-separated segments");
for (const s of segs) {
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(s)) errs.push(`segment "${s}" must start with a letter`);
if (RESERVED.has(s)) errs.push(`segment "${s}" is a Java reserved word`);
if (/[A-Z]/.test(s)) errs.push(`segment "${s}" contains uppercase characters`);
}
return errs;
}
function checkBundleId(id) {
const errs = [];
if (!id) return ["ios.bundleIdentifier is not set"];
if (!/^[A-Za-z0-9.-]+$/.test(id)) errs.push("contains characters that are not allowed");
if (id.split(".").length < 2) errs.push("fewer than two dot-separated segments");
if (/_/.test(id)) errs.push("underscores are not allowed");
return errs;
}
const path = process.argv[2] ?? "app.json";
const cfg = readConfig(path);
if (!cfg) { console.error(`x ${path} not found`); process.exit(2); }
const ios = cfg.ios?.bundleIdentifier;
const android = cfg.android?.package;
const findings = [];
for (const e of checkBundleId(ios)) findings.push(["BLOCK", "ios.bundleIdentifier", e]);
for (const e of checkAndroidPackage(android)) findings.push(["BLOCK", "android.package", e]);
for (const [key, val] of [["ios.bundleIdentifier", ios], ["android.package", android]]) {
if (val && PLACEHOLDER.some((re) => re.test(val))) {
findings.push(["BLOCK", key, `"${val}" looks like an untouched placeholder`]);
}
}
if (ios && android && ios.toLowerCase() !== android.toLowerCase()) {
findings.push(["WARN", "ios/android", `identifiers differ (iOS: ${ios} / Android: ${android})`]);
}
if (!cfg.name) findings.push(["WARN", "name", "display name is not set"]);
if (!cfg.slug) findings.push(["WARN", "slug", "slug is not set"]);
console.log(`# ${path}`);
console.log(` ios : ${ios ?? "(none)"}`);
console.log(` and : ${android ?? "(none)"}`);
if (findings.length === 0) { console.log("ok - safe to lock in"); process.exit(0); }
for (const [lv, key, msg] of findings) console.log(` [${lv}] ${key}: ${msg}`);
process.exit(findings.some(([lv]) => lv === "BLOCK") ? 1 : 0);It looks for three things: a placeholder left in place, a word Android cannot accept in a package name, and a mismatch between the iOS and Android identifiers.
The second one deserves a note. Expo uses android.package for both the Gradle applicationId and the generated Java / Kotlin package. Put a reserved word such as new or class into a segment and the configuration reads perfectly well, but compilation fails — and the package name is rarely the first place you go looking.
Here is the actual output across four fixtures on my machine.
$ node check-app-ids.mjs a-default.json
# a-default.json
ios : com.anonymous.wallpaper
and : com.anonymous.wallpaper
[BLOCK] ios.bundleIdentifier: "com.anonymous.wallpaper" looks like an untouched placeholder
[BLOCK] android.package: "com.anonymous.wallpaper" looks like an untouched placeholder
exit=1
$ node check-app-ids.mjs b-mismatch.json
# b-mismatch.json
ios : net.dolice.calmwall
and : net.dolice.calm_wall
[WARN] ios/android: identifiers differ (iOS: net.dolice.calmwall / Android: net.dolice.calm_wall)
exit=0
$ node check-app-ids.mjs c-reserved.json
# c-reserved.json
ios : net.dolice.new.zentimer
and : net.dolice.new.zentimer
[BLOCK] android.package: segment "new" is a Java reserved word
exit=1
$ node check-app-ids.mjs d-ok.json
# d-ok.json
ios : net.dolice.zentimer
and : net.dolice.zentimer
ok - safe to lock in
exit=0The second case is a WARN rather than a BLOCK on purpose. calm_wall is a perfectly legal Android package name; underscores are only rejected on the iOS side. A mismatch there is sometimes the residue of trying to keep the two aligned, and that is not something a script can adjudicate for you. So it reports and steps aside.
Because it exits with a status code, it drops straight into an npm run script or a pre-build hook.
If you plan to ship more than one, settle the rule first
Even if you only intend to ship one app, a build that goes well tends to produce a second. What carries over is the naming rule you set on the first.
Across my six apps I use two or three segments only: <reversed domain>.<short product name>. No platform, no version number, no distribution model. The more meaning you load into an identifier, the more of it becomes stale and unmovable.
There is a second, less obvious benefit. When every app follows the same shape, you can derive the identifier from a single variable in your build config rather than hand-writing it per app. A rule that a script can reproduce is a rule you cannot mistype at two in the morning, which is roughly when I have historically done my worst naming.
Load whatever you like into the display name instead. That one you can change later. Put information that changes where change is allowed, and only stable information where it is not — that split is what has held up best for me.
The concrete build setup for shipping several apps from one codebase, down to how the build profiles are separated, is in Shipping six wallpaper apps from one codebase — a white-label setup with app.config.ts and EAS.
If you have already shipped
Some of you will be reading this after the fact. There are two paths.
Keep it. Outside the Android store URL, the identifier is almost never visible to the people using your app. Changing the display name resolves the cosmetic side of it. Two of my six still carry the off-pattern identifier for exactly this reason — the mismatch is the kind of thing only I will notice, years from now.
Republish under a new identifier. It helps to know what does not come with you: download counts, ratings and reviews, ranking history, and automatic updates for existing users. You will need to build your own path from the old app to the new one. Code, assets, and product definitions do carry over, though in-app purchase products have to be recreated on the store side.
One clarification before you go looking for a loophole: deleting the app record does not release the identifier. On App Store Connect a bundle ID that has been attached to an app record stays reserved to your account even after the record is removed, so you cannot recreate the app under the same string. On Google Play the package name is reserved permanently once a build has been uploaded to any track, internal testing included. In both cases the practical move is to choose a new identifier, not to try to reclaim the old one.
For me the deciding factor is the review count. With a handful of reviews the cost of republishing is small; once you are into the hundreds, living with an untidy identifier is almost certainly the better trade.
The thirty minutes before you register
- Open
app.jsonorapp.config.tsand look atios.bundleIdentifierandandroid.package - Run the script above and fix everything until BLOCK count reaches zero
- Re-read the tail of the identifier for anything like
free,v2,2026, orios - Write down, in a single line, how you would extend the rule for a second app
Do all four before you create the record in App Store Connect and Play Console. In the other order, the things you could have fixed are no longer fixable.
The day you ship your first app is crowded with decisions, and quiet fields like a name tend to slide to the end of the list. Mine did. If this helps you protect thirty minutes of that day, I am glad.