Migrating from Cordova to Capacitor

This post aims to document my experience migrating my Meteor 3.2 app from Cordova to Capacitor. It’s still a work in progress because I haven’t released the app to the app stores yet; I need to get in-app updates working (which I’ll talk about below). But for the most part, it seems very promising.

@nachocodoner’s post in this forum thread was the basis for me (and Claude) figuring out how to make this happen.

Update: new Capacitor plugin for Meteor

I made a plugin that ports cordova-plugin-meteor-webapp to Capacitor (iOS only right now, Android to follow) and simplifies the build process while adding hot-code-push support and better backward compatibility.

A full set of instructions for how to get Capacitor up and running in a Meteor project (using that plugin) are available in the README linked above.

Initial method for getting Capacitor working

NOTE: The original instructions I posted here do not apply when using the @banj

Expand to see original instructions

Note, these steps are, to some extent, written from memory. I may be missing steps; let me know if I missed any important ones.

First, I added Capacitor to the projects per the docs:

meteor npm i @capacitor/core
meteor npm i -D @capacitor/cli
npx cap init

You will be asked some questions about how to set things up. Note, I chose to put all Capacitor-related files in the capacitor directory, with capacitor/www-dist as the directory that the Meteor-built files would be copied into, and capacitor/(ios|android) as the directory where the Xcode/Android projects would be built and checked into git. Here’s the basic capacitor.config.ts config to set this up:

import type { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.myapp.id',
  appName: 'My App Name',
  webDir: 'capacitor/www-dist',
  ios: { path: `capacitor/ios`, },
  android: { path: `capacitor/android`, }
};

export default config;

Then it’s time to set up the native project(s):

npx cap add ios

WebAppLocalServer shim

I put this in capacitor/WALS-shim.html for use later on in the build script:

<script>
  // WebAppLocalServer compatibility shim (replaces cordova-plugin-meteor-webapp)
  if (typeof WebAppLocalServer === 'undefined') {
    window.WebAppLocalServer = {
      isShim: true,
      startupDidComplete: function(cb) { cb && cb(); },
      checkForUpdates: function(cb) { cb && cb(); },
      onNewVersionReady: function() { /* noop */ },
      switchToPendingVersion: function(cb) { cb && cb(); },
      onError: function(cb) { /* noop */ },
      localFileSystemUrl: function() { return 'INVALID_LOCAL_FILESYSTEM'; }
    };
  }
</script>

Build script

Now for the (first) tricky part: getting the Meteor client assets into the capacitor/www-dist directory. Again, please read @nachocodoner’s post linked above to get the higher-level overview of what’s we’re doing here and what’s required.

Then, check out the script below, which automates this process. Note, I have this in my Justfile with double-bracketed variables like {{root_url}}; you would have to replace these with your values as needed if you’re not using just.

Note, this script is specific to iOS (and Mac’s version of sed for that matter), but it could be adapted to work with Android as well.

#!/usr/bin/env sh
set -euxo pipefail
cleanup() {
  kill $SERVER_PID 2>/dev/null || true
}
trap cleanup SIGINT SIGTERM  # cleanup on Ctrl+C
export PORT=3333
export ROOT_URL='{{root_url}}'
# Version number for mobile app build - this is particular to the way I do version numbers on mobile
# We need something here because we're no longer using mobile-config.js
export MOBILE_VERSION="1.$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version.replace(/^(\d+\.\d+).*/, '\$1')")"
# Need a valid MongoDB connection to boot server; 
# I grab this from SOPS-encrypted file in repo but you could theoretically run a local Mongo server too
export MONGO_URL=$(sops exec-env .deployment/secrets-qa.sops.env 'echo $MONGO_URL')
rm -rf {{build_dir}}
# Assumes public Meteor settings for mobile are at .deployment/mobile-settings.json
meteor build --directory {{build_dir}}/ --headless --server-only --mobile-settings ".deployment/mobile-settings.json" --server $ROOT_URL
rm -rf capacitor/www-dist || exit 1
mkdir -p capacitor/www-dist
cp -r {{build_dir}}/bundle/programs/web.cordova/*.js capacitor/www-dist || exit 1
cp -r {{build_dir}}/bundle/programs/web.cordova/app/* capacitor/www-dist/ || exit 1
rm -rf capacitor/www-dist/**/*.map || exit 1  # remove sourcemaps
(cd {{build_dir}}/bundle/programs/server && npm install --no-audit --loglevel error --omit=dev)
# Start server in background and capture PID
node {{build_dir}}/bundle/main.js &
export SERVER_PID=$!
sleep 3
# Fetch index.html and patch cordova paths
curl -f --connect-timeout 20 'http://localhost:3333/__cordova/index.html' | \
  sed -e 's|/__cordova/client/|/client/|g' -e 's|/__cordova/|/|g' \
  > capacitor/www-dist/index.html
# Inject WebAppLocalServer shim script
sed -i '' "3r capacitor/WALS-shim.html" capacitor/www-dist/index.html
# Sync www files to Capacitor
npx cap sync
# Sync version number and bump build number
(cd capacitor/ios/App/App.xcodeproj/; \
  sed -i '' "s/MARKETING_VERSION = [^;]*;/MARKETING_VERSION = $MOBILE_VERSION;/g" project.pbxproj; \
  awk '/CURRENT_PROJECT_VERSION = / { match($0, /[0-9]+/); gsub(/[0-9]+/, substr($0, RSTART, RLENGTH)+1) } 1' project.pbxproj > temp && mv temp project.pbxproj; \
)
# Open Xcode
npx cap open ios
cleanup

At this point, the project should open in Xcode and run successfully.


Cordova plugins

You’ll need to add Cordova plugins you depend on to your package.json, but they should “just work”. I ended up switching to official Capacitor plugins in most cases, but I didn’t bother migrating my custom Cordova plugins.

Hot Code Push

I initially looked into @nachocodoner recommendation of using Capgo for this. Seems like a good service, but I preferred the idea of sticking with a “Meteor-native” approach of downloading assets and swapping them out, which is why I ported cordova-plugin-meteor-webapp to Capacitor (with a lot of help from Claude). It leverages Capacitor’s native setServerBasePath() method instead of running a custom HTTP server on iOS, so it’s simpler. I kept the download logic, state management, and recovery mechanisms from the original Cordova plugin (including many files with no changes at all) while using Capacitor’s built-in web serving capabilities for atomic bundle switching.

Android support

Haven’t even touched the Android side of things, but there’s no reason to believe that the Android part of cordova-plugin-meteor-webapp couldn’t also be ported over to Capacitor. PRs welcome! I suggest we try to reuse as much of the old code as possible (as I’ve tried to do with iOS) so that we’re not reinventing the wheel and potentially introducing new bugs.

What about dev mode?

Haven’t figured out a good workflow for testing in development. I mean, I can set ROOT_URL to a local web server and it will connect, but to update the code I have to run the whole build script again, which takes about 90 seconds on my M4 MacBook Air.

Then again, I’m using meteor-vite and it’s never worked in dev mode with Cordova. If I switch back to Meteor bundling (i.e. with Rspack) someday, maybe I’ll look into this more. I suspect it would require changes to the Meteor core… not sure though.


I hope this might be a step in helping us Meteor devs to be able to use Capacitor in as smooth a way as possible, and perhaps getting Capacitor support in the Meteor core someday.

Let me know if you try this or have any ideas for how to improve things.

7 Likes

Oh, and now I will share my actual config, which supports multiple bundle IDs and mobile server URLs. Took me awhile to figure out a good way to do this but I’m quite pleased now.


Directory structore

capacitor
├── Justfile
├── projects
│   ├── com.myapp.mobileapp
│   │   └── ios
│   ├── com.myapp.mobilebeta
│   │   └── ios
│   └── com.myapp.mobiledev
│       └── ios
├── WALS-shim.html
└── www-dist
    ├── 577bf1c44c857cea41071ab15397ec6f6c06d8a2.js
    ├── favicon.ico
    ├── [other files in /public]
    └── index.html

capacitor.config.ts

import type { CapacitorConfig } from '@capacitor/cli';

const isProd = process.env.ROOT_URL === 'https://myapp.com';
const isBeta = process.env.ROOT_URL === 'https://beta.myapp.com';

const bundleId = isProd
  ? 'com.myapp.mobileapp'
  : isBeta
    ? 'com.myapp.mobilebeta'
    : 'com.myapp.mobiledev';

const appName = isProd ? 'My App' : isBeta ? 'MyApp Beta' : 'MyApp Dev';

const config: CapacitorConfig = {
  appId: bundleId,
  appName,
  webDir: 'capacitor/www-dist',
  loggingBehavior: 'none', // Capacitor logs are too noisy
  ios: {
    path: `capacitor/projects/${bundleId}/ios`,
  },
  android: {
    path: `capacitor/projects/${bundleId}/android`,
  },
  backgroundColor: '#265C86',
  plugins: {
    'SplashScreen': {
      'launchAutoHide': false,
      'launchFadeOutDuration': 300,
    },
  },
};

export default config;

Justfile

Note: This is from before I started using my @banjerluke/capacitor-meteor-webapp plugin, but the idea is the same.

# List all commands as default command
list:
  @just --list --unsorted

# Build iOS beta app
[group('mobile')]
build-ios-beta:
  @just build-ios com.strummachine.mobilebeta https://beta.strummachine.com "SM Beta"

# Build iOS dev app
[group('mobile')]
build-ios-dev:
  @just build-ios com.strummachine.mobiledev https://3k.fdf.sh "SM Dev"

# Build iOS app
[group('mobile')]
build-ios-prod:
  @just build-ios com.strummachine.mobileapp https://strummachine.com "Strum Machine"

# Build iOS app
[group('mobile')]
build-ios bundle_id='com.strummachine.mobileapp' root_url='https://strummachine.com' display_name='Strum Machine':
  #!/usr/bin/env sh
  set -euxo pipefail
  cleanup() {
    kill $SERVER_PID 2>/dev/null || true
  }
  trap cleanup SIGINT SIGTERM  # cleanup on Ctrl+C
  export PORT=3333
  export ROOT_URL='{{root_url}}'
  # Version number for mobile app build
  export MOBILE_VERSION="1.$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version.replace(/^(\d+\.\d+).*/, '\$1')")"
  # Dummy API key to boot server
  export STRIPE_API_KEY='sk_test_asdf'
  # Need a valid MongoDB connection to boot server
  export MONGO_URL=$(sops exec-env .deployment/secrets-qa.sops.env 'echo $MONGO_URL')
  rm -rf {{build_dir}}
  meteor build --directory {{build_dir}}/ --headless --server-only --mobile-settings ".deployment/mobile-settings.json" --server $ROOT_URL
  rm -rf capacitor/www-dist || exit 1
  mkdir -p capacitor/www-dist
  cp -r {{build_dir}}/bundle/programs/web.cordova/*.js capacitor/www-dist || exit 1
  cp -r {{build_dir}}/bundle/programs/web.cordova/app/* capacitor/www-dist/ || exit 1
  rm -rf capacitor/www-dist/**/*.map || exit 1  # remove sourcemaps
  sed -i '' "s|WebAppLocalServer\.onError(function(e){console\.error(e)})||g" capacitor/www-dist/*.js || exit 1
  (cd {{build_dir}}/bundle/programs/server && npm install --no-audit --loglevel error --omit=dev)
  # Start server in background and capture PID
  node {{build_dir}}/bundle/main.js &
  export SERVER_PID=$!
  sleep 3
  # Fetch index.html and patch cordova paths
  curl -f --connect-timeout 20 'http://localhost:3333/__cordova/index.html' | \
    sed -e 's|/__cordova/client/|/client/|g' -e 's|/__cordova/|/|g' \
    > capacitor/www-dist/index.html
  # Sync www files to Capacitor
  npx cap sync
  # Sync version number and bump build number
  (cd capacitor/projects/{{bundle_id}}/ios/App/App.xcodeproj/; \
    sed -i '' "s/MARKETING_VERSION = [^;]*;/MARKETING_VERSION = $MOBILE_VERSION;/g" project.pbxproj; \
    awk '/CURRENT_PROJECT_VERSION = / { match($0, /[0-9]+/); gsub(/[0-9]+/, substr($0, RSTART, RLENGTH)+1) } 1' project.pbxproj > temp && mv temp project.pbxproj; \
  )
  # Open Xcode
  npx cap open ios
  cleanup

Now I run just build-ios-dev, just build-ios-beta, or just build-ios-prod and get the appropriate app built.

6 Likes

I almost wonder if HCP is necessary, or if at least this can be packaged and launched without it.

With Meteor, you will already run into issues with HCP if your Cordova config/plugins change, in which case HCP no longer works for the older builds. Then you have to support three versions of the app: what’s live, what’s on the App Stores, and the “last compatible” version that some users received via HCP.

It could be packaged and launched without HCP, sure. The script above is getting me a working app that I can install on any device or upload to the app store, so it’s “mission accomplished” in that regard.

Except… HCP is not something I can give up at this point. It’s worked fabulously for my app for years now. I got around the issue you mention by always building with fixed Cordova versions:

export METEOR_CORDOVA_COMPAT_VERSION_IOS="some_fixed_id"
export METEOR_CORDOVA_COMPAT_VERSION_ANDROID="another_fixed_id"

That way I only have to support the latest version of the app (although I’m always careful to do my best to maintain backward compatibility for at least a few months, in case users haven’t opened their app in awhile or haven’t received the app store update).

The only consideration is that I cannot make any assumptions about the Cordova (or Capacitor) plugins that are available. But that’s hardly a problem; I just check for the existence of the API and use a fallback if it’s not available.

I’m so thankful that HCP exists. It’s saved my bacon more than once with being able to release hotfixes (especially ones for bugs that break the mobile app only) without having to wait for app store review. Plus it’s nice to know that folks are using the latest version more-or-less always… I can see from my analytics that sometimes it takes users a LONG time to get the app store update.

Anyway, I’m making good progress on the Capacitor migration for cordova-plugin-meteor-webapp (which provides HCP capability) so I don’t think this is something that will need compromise. Although maintaining the METEOR_CORDOVA_COMPAT_VERSION behavior could be tricky. For my purposes it doesn’t matter though.

2 Likes

It would be awesome to see someone doing a demo of this at the impact conference this year

3 Likes

Sounds super exciting. Do you think capacity-community/electron would work seamlessly?

I ported cordova-plugin-meteor-webapp to Capacitor!

It’s iOS-only at this point (only because I haven’t tackled Android yet and have less experience with that platform) and not thoroughly tested or reviewed (i.e. not calling it production-ready yet), but it WORKS!

There are a full set of instructions (different from those in my first post) for how to get Capacitor running with Meteor in the README. The plugin changes the way files are loaded to match how the Cordova plugin works, so there’s no longer a need to massage the output in the built server’s web.cordova directory.

We also need to shim window.WebAppLocalServer and proxy that API to CapacitorMeteorWebApp, so that the Meteor runtime is able to interact with window.WebAppLocalServer as usual and remain blissfully ignorant that we’re not in Cordova anymore. The banjerluke:capacitor-meteor-webapp-shim Meteor package will do this for you. As of v0.1.0 the Capacitor plugin adds this directly to the index.html file before serving it, so it’s all taken care of.

The build process still feels a little “hacky”, but it’s a good DX now (one build script). I am curious to see what Meteor maintainers might think of adapting the core to make this script less hacky, perhaps outputting a static index.html and program.json with a version field so that there was no need to boot up the server.

I will be out of town during the conference but I encourage anyone to take this as a starting point and run with it!

Haven’t tried, and probably won’t (ToDesktop has been working flawlessly for me) but I don’t see why it wouldn’t work! When all is said and done, you’re left with a pretty standard Capacitor setup, so any Capacitor plugin should work…

6 Likes

Question: what’s the real difference between capacitor and cordova? In the end they are both a webview running your app, no? What’s the benefit of migrating to capacitor?

1 Like

Hey @banjerluke! Great to see your work on this approach. Even if it feels a bit hacky, having it running is a big relief each time, I know that.

I have a few comments:

Dev mode: What worked for me was relying on Capacitor’s built-in live reload. If you update the www folder (in your case capacitor/www-dist), changes reload automatically. For development mode only, I use this in my Capacitor config:

"server": {
	"url": "http://{{LOCAL_IP}}:{{PORT}}",
	"cleartext": true
},

The tricky part is enabling a watcher that runs your build script whenever changes happen in Meteor’s Cordova context (.meteor/local/build/programs/web.cordova). In my setup, dev mode runs Meteor with the Cordova arch enabled, plus a background watcher on that folder. Any effective change from Meteor triggers my process to rebuild www-dist, which then refreshes automatically in the emulator.

HCP: Making cordova-plugin-meteor-webapp work with Capacitor is nice, but it might conflict with Capacitor’s own local webview/server. I didn’t go too deep into it, since the Capgo approach felt smoother and easier to reason about. Meteor HCP does a lot for what is essentially just replacing the native bundle.

Capgo can be used without their paid service. I used its API (CapacitorUpdater.download / CapacitorUpdater.set, and having much more utilities) to build a simple mechanism that prompts an update if a new bundle is available in a AWS S3 bucket and the user is on an older native build. That bucket just holds the zipped, versioned www-dist. Capgo fetches and replaces it cleanly. I could have used Meteor’s own express app to serve the bundle, but chose S3 to offload it. Still, I may experiment with a more integrated express-based approach when I go deeper into Meteor–CapacitorJS integration. But having cordova-plugin-meteor-webapp compatible with your experiments would also be great!

1 Like

Hey @matheusccastro, glad to see you here :blush:

Capacitor and Cordova both follow the webview approach, allowing the same web code to run on native platforms with little effort.

The main reasons to migrate are features and maintenance. Cordova itself still gets updates, but the ecosystem depends heavily on plugins, and many of them are abandoned, deprecated, or not updated with the latest native APIs. A clear example is RevenueCat’s Cordova plugin for cross-platform payments, which is being deprecated in favor of its CapacitorJS version. This will happen more often, and Capacitor ensures plugins evolve and integrate better with the latest native features.

Some key differences:

  • Native projects: Cordova generates them as build artifacts (changes can be overwritten). Capacitor treats them as independent projects that you keep and commit.
  • Plugins: Cordova relies on an old plugin ecosystem, while Capacitor plugins are modern, Promise-based, easier to write, and actively maintained.
  • Web support: Capacitor APIs often work in the browser too (PWA support). For example, camera or file management plugins run both natively and on the web, with dedicated implementations for each in a single module.
  • Developer experience: Capacitor encourages direct use of Xcode/Android Studio, integrates more smoothly with modern SDKs, and provides typed, well-documented APIs.
  • Features & Maintenance: Both Capacitor core and its plugin ecosystem are actively updated with new features and support for the latest native APIs. This matters because it lets you rely more on the open source community instead of maintaining your own core and plugin forks and have experts on each of them.

Meteor moving to CapacitorJS is one of the smoothest integrations possible. CapacitorJS feels like the natural successor to CordovaJS, with built-in compatibility for Cordova plugins. This makes the transition simple for existing native Meteor projects, though it’s recommended to migrate to modern Capacitor alternatives over time.

3 Likes

I don’t think this is true in reality – at least I have not had any issues with it in my local testing (still working on porting tests from the old plugin). And that’s because I am using the same approach that Capgo is using. The Capacitor bridge object exposes setServerBasePath, which lets you change where files are served from. So instead of serving directly from the bundled public folder, it serves from a different folder.

At first, the plugin “organizes” the public folder (which is taken straight from web.cordova build output, with an app directory and __cordova paths in index.html and everything) into an “initial bundle” that is then served by Capacitor using setServerBasePath. When a new bundle is registered by Meteor, it is fetched and “organized” into a directory (based on it’s program.json manifest) and then once WebAppLocalServer.switchToPendingVersion() is called (which Meteor does automatically for Cordova), Capacitor is told to serve files from that folder instead. It’s a much more “native” approach than what was being done before in Cordova, since we don’t have to bundle our own local web server. (WebAppLocalServer is kind of a misnomer now, but it’s kept for backwards compatibility.)

Capgo seems nice, but for an existing Meteor application – and for anything that might be considered as an “official” Meteor-Capacitor integration someday – I think keeping backwards compatibility with Meteor Cordova HCP is definitely the way to go. Why add a new dependency and complicate the deployment process? It also shifts the burden of adapting a web.cordova Meteor bundle from a build script (s/__cordova//g and so forth) to the Capacitor plugin.

For a new app, Capgo might be favorable for certain reasons. But for an existing Meteor app migrating from Cordova to Capacitor, maintaining HCP would be a smoother transition. It’s definitely what I’m going to do, in any case.

I think maintaining backward compatibility with Meteor HCP would also solve dev mode, without the need for a custom watch/sync script to finagle web.cordova output. I haven’t gotten into that yet though.

My plugin is still “unstable” in that I haven’t finished porting over the tests from cordova-plugin-meteor-webapp… not to mention I haven’t touched Android, though that should be a straightforward port. But it is working well in testing on my devices. I did start working on tests in the swift-testing branch, and I think the approach is solid (testing with swift test outside of an actual Capacitor environment by mocking the Capacitor bridge and external APIs) but I need to focus on some more pressing issues with my app right now.

1 Like

Yes, your reasoning makes sense. Backwards compatibility is the best path. For my app it has been more than enough, and removing much of Meteor’s overconfiguration was a big relief. It’s also good to see that we can keep some of the way the Meteor plugin handled files and live updates. Otherwise, we can rely on what Capacitor already offers with live reloads for dev mode, plus a plugin for HCP. Either way, Capacitor will be integrated with Meteor.

Why add a new dependency and complicate the deployment process?

Depending on the dependency, cordova-plugin-meteor-webapp has been hard to maintain and to publish new versions without risking breakage. I prefer to rely on a more popular, delegated dependency with many eyes reviewing it in the ecosystem of the tool we’re adopting, like Capacitor. We can keep backward compatibility by using that external plugin to hook into Meteor so end users don’t notice.

Same reasoning applies to the latest Rspack integration. Who could have imagined a more native approach to Meteor, with tree shaking, ESM support, and the other features a modern bundler provides today? There have been attempts before, but none were completed and the motivation faded. Yes, we are adding a new dependency, but I believe it’s for the best.

I’m also open to experimenting to revive cordova-plugin-meteor-webapp, understand this piece better, ensure proper and accurate testing, and ship future updates.


Let’s continue experimenting. After the modern bundler integration, I’ll have more time to join in and help explore the options for keeping native HCP while still using Capacitor’s modern features without regressions. I will keep an eye on your work here.

I wasn’t very clear about my hesitation around a “new dependency”, so just to clarify: I’m reluctant to introduce dependencies to new services (Capgo paid service, S3 bucket, etc.) more than depending on external libraries per se. Not because of cost (Capgo pricing is very reasonable and S3 is dirt cheap) but overhead and complexity and wanting to keep things simple.

In general, I think having dependencies on well-established libraries in the JS ecosystem is smart. I’m very excited about the Rspack integration, for example.

Besides, I’m fully aware that a Capacitor port of cordova-plugin-meteor-webapp would also be a “new dependency”, and one with less eyes on it than a library like Capgo, so it’s certainly no panacea.

Looking at the Capgo API a bit closer, it seems like I could potentially have the Meteor server itself replace the hosted Capgo service by responding to API POST calls on a self-hosted endpoint. I guess this is what you’re doing. I am intrigued and may look into this further. But I would feel bad making any sort of public or official integration like this, because it would circumvent Martin’s hosted service and he should be supported for his work.

(Not to mention that I could never get the Capgo update process to behave as smoothly and with as much control over timing as with the Meteor approach, but that could be some combination of user error and having a large bundle size due to tens of megabytes of audio assets in the bundle.)

Thanks for all your thoughts @nachocodoner. Exciting stuff.

1 Like