DEV Community

BellSal
BellSal

Posted on

One codebase, one website, two app stores: what Capacitor actually costs you

My photo collage editor was a static site: vanilla JS, a canvas, no framework. Putting it on the Play Store and the App Store with Capacitor took a weekend of work and about three weeks of small surprises. The surprises are the interesting part, so here they are.

Don't ship the website inside the app

The instinct is to point Capacitor at your existing web root and be done. Don't. A website root contains a pile of things that make no sense inside an app and some that will actively hurt you: sitemap.xml, robots.txt, a service worker, a cookie consent script, landing pages, llms.txt.

I ended up with a build step that generates a separate app-only bundle. It copies the public folder while filtering out every .html file and an explicit deny list of site-only assets, then rebuilds a single index.html from a native shell template:

const siteOnlyFiles = new Set([
  "ads.txt", "consent-tracking.js", "home-seo.js", "llms.txt",
  "manifest.webmanifest", "pwa.js", "robots.txt",
  "service-worker.js", "sitemap.xml", "social-preview.png"
]);

fs.cpSync(publicDir, outputDir, {
  recursive: true,
  filter(source) {
    if (source === publicDir) return true;
    const name = path.basename(source);
    if (siteOnlyFiles.has(name)) return false;
    return path.extname(name).toLowerCase() !== ".html";
  }
});
Enter fullscreen mode Exit fullscreen mode

Then it pulls the editor markup out of the website's homepage by slicing between two known markers and injects it into the native template:

const workspaceStart = siteHtml.indexOf('<section class="workspace editor-workspace"');
const workspaceEnd = siteHtml.indexOf('<section class="content-band">', workspaceStart);

if (workspaceStart < 0 || workspaceEnd < 0) {
  throw new Error("Could not extract the shared collage editor from index.html.");
}
Enter fullscreen mode Exit fullscreen mode

Yes, this is string-slicing HTML, and yes, that is usually a terrible idea. The reason I kept it: the throw turns a refactor of the homepage into a failed build rather than an app that ships with an empty screen. A brittle step that fails loudly beats a clever one that fails silently. If you do this, make the failure mode explicit.

The word "free" can get your build flagged

This one I did not see coming. My translation file is shared between the site and the app, and the site copy says things like "free, no signup, no watermark" because that is what the landing page is selling.

Store metadata scanners read the strings inside your bundle. Price language in shipped assets is a good way to get a conversation you don't want with a review team. The build step now rewrites those specific strings for the packaged version: the app says "Create photo collages", the website keeps its copy.

If you share an i18n file between a marketing site and an app, audit it for price and promotional wording before you submit.

The download button that does nothing

The single biggest functional break. On the web, saving the collage was an <a download> pointing at a data URL. In an Android WebView that silently does nothing: no error, no console warning, no download. The button just doesn't work.

The fix was a small native plugin that writes to MediaStore, plus a shim that intercepts the anchor click so the shared editor code never has to know which environment it's in:

var originalClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
  if (this.download && /^data:image\//.test(this.href || "")) {
    saveDataUrl(this.href, this.download);   // native plugin -> MediaStore
    return undefined;
  }
  return originalClick.apply(this, arguments);
};
Enter fullscreen mode Exit fullscreen mode

Monkey-patching a DOM prototype is not something I'd normally advocate, but the alternative was branching every save path in shared code. I wrote about that bug in more detail in an earlier post.

Capacitor 8 wants JDK 21, and your machine probably has 17

My system JAVA_HOME pointed at JDK 17, which produced a Gradle error that does not obviously say "wrong Java version". The build script now finds a JDK 21 itself (Android Studio bundles one in jbr) and only falls back to JAVA_HOME after verifying it actually is 21:

function isJdk21(dir) {
  const out = execFileSync(path.join(dir, "bin", "java.exe"), ["-version"], {
    encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
  });
  return /version "21|openjdk 21|build 21/.test(out);
}
Enter fullscreen mode Exit fullscreen mode

Checking the version instead of trusting the variable took ten minutes and has saved me the same debugging session at least three times.

Debug builds must force test ads

If you monetise with AdMob, tapping your own real ad while testing on your own phone is a policy violation. Rather than remembering not to tap things, the debug build rewrites the ad config in the generated bundle:

if (mode === "debug") {
  for (const name of ["admob.js", "premium.js"]) {
    const file = path.join(root, "android-web", name);
    fs.writeFileSync(file,
      fs.readFileSync(file, "utf8").replace("IS_TESTING: false", "IS_TESTING: true"));
  }
}
Enter fullscreen mode Exit fullscreen mode

Make the safe thing automatic, because you will forget.

You don't need a Mac for the iOS build

I built and shipped the iOS version from a Windows machine. Codemagic runs the Mac, and the whole config is short enough to read in one screen:

workflows:
  ios-release:
    instance_type: mac_mini_m2
    environment:
      node: 22
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.example.app
    scripts:
      - script: npm ci
      - script: npm run build:android-web
      - script: npx cap sync ios
      - script: xcode-project use-profiles
      - script: |
          xcode-project build-ipa --project ios/App/App.xcodeproj --scheme App
    publishing:
      app_store_connect:
        auth: integration
        submit_to_testflight: true
Enter fullscreen mode Exit fullscreen mode

One gotcha: running npx cap sync ios on Windows rewrites paths in Package.swift in a way that macOS won't accept. Let the CI machine do the iOS sync and don't commit the result from Windows.

Was it worth it?

For a canvas-based tool with no server, yes. The editor is genuinely the same code in all three places, and a bug fix ships everywhere. The cost was not the packaging, it was the seams: file saving, ad policy, store metadata, and a build pipeline that now has opinions about which files belong where.

What I would tell past me: budget your time for the platform boundaries, not for the port. The port is a weekend. The boundaries are the rest of the month.

The result, if you want to poke at it: freecollageimage.com, and the same editor as an Android app and an iOS app. All mine, all free.

Top comments (0)