RORK LABJP
NATIVE — Because Rork Max generates pure Swift and SwiftUI, it reaches AR and LiDAR, Metal-backed 3D, Home Screen widgets, Dynamic Island, Live Activities, HealthKit, NFC and Core MLCHOICE — Put the other way around, if your app touches none of those Apple-specific capabilities, the regular cross-platform Rork is enough. That single question decides whether Max is worth itPLAY — From August 31, new apps and app updates on Google Play must target API level 36, or Android 16. Nine days remainPLAY — Even an app you are not updating needs to target API level 35 or higher to keep reaching new users on newer Android devices. Miss it and only new installs quietly stopGRACE — An extension form in Play Console buys you until November 1, but it is not automatic. The request itself has to be filed before August 31EXPO — expo prebuild now clears and regenerates the native directories by default. Pass no-clean if you have edited them by hand, which is easy to trip over mid API 36 migrationNATIVE — Because Rork Max generates pure Swift and SwiftUI, it reaches AR and LiDAR, Metal-backed 3D, Home Screen widgets, Dynamic Island, Live Activities, HealthKit, NFC and Core MLCHOICE — Put the other way around, if your app touches none of those Apple-specific capabilities, the regular cross-platform Rork is enough. That single question decides whether Max is worth itPLAY — From August 31, new apps and app updates on Google Play must target API level 36, or Android 16. Nine days remainPLAY — Even an app you are not updating needs to target API level 35 or higher to keep reaching new users on newer Android devices. Miss it and only new installs quietly stopGRACE — An extension form in Play Console buys you until November 1, but it is not automatic. The request itself has to be filed before August 31EXPO — expo prebuild now clears and regenerates the native directories by default. Pass no-clean if you have edited them by hand, which is easy to trip over mid API 36 migration
Articles/Getting Started
Getting Started/2026-08-22Beginner

The names you can change, and the ones you can't — the 30 minutes before your first Rork release

An app has three names, and only the display name can be changed after release. Here is how I check the identifiers Rork fills in by default, using a dependency-free script, before I hit submit.

Rork542Expo178App Store89Google Play28bundleIdentifiergetting started

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.

FieldApp StoreGoogle PlayChangeable later
Display nameChange with a new version reviewChange from the store listingYes
Icon and screenshotsSwapped per versionSwapped any timeYes
Description and categoryChange with reviewChange any timeYes
Bundle ID / package nameLocked once registered in App Store ConnectLocked once publishedNo
SKUSet at registrationNot applicableNo
Store URLA numeric ID assigned at registrationThe package name appears directly in the URLNo

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.net becomes net.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=0

The 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

  1. Open app.json or app.config.ts and look at ios.bundleIdentifier and android.package
  2. Run the script above and fix everything until BLOCK count reaches zero
  3. Re-read the tail of the identifier for anything like free, v2, 2026, or ios
  4. 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.

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 →

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

Getting Started2026-08-19
You Cannot Shorten Review, But You Can Still Choose the Moment Your App Goes Live
Review time is out of your hands. The moment your app appears in the store is not. Here is how the three App Store Connect release options, Google Play managed publishing, and eas submit fit together.
Getting Started2026-04-18
What Actually Happens When You Ship a Rork App to iOS and Android at the Same Time
A practical account of releasing a Rork app to both the App Store and Google Play simultaneously — covering screenshot specs, privacy manifests, SDK requirements, review timing, and what actually trips you up along the way.
Getting Started2026-08-17
Write .gitignore Before You Run git init on an Exported Rork Project
Running git init on a freshly exported Rork project puts your signing keys and .env straight into history. I measured what gets committed when the ignore file comes first versus last, and which exclusion patterns actually work.
📚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 →