Building Capacitor mobile App from Meteor+React

Hello all,

TL;DR:

After a year of working with @victor at Allohouston on building apps with Meteor, we’ve been working more recently on integrating CapacitorJS into a Meteor + React project that needed a native app to enable background geolocation. This post shares our path trying to generate a mobile app with Cordova first, then Capacitor; the challenges faced (notably with @tanstack/react-query@5and Meteor’s “modern” client detection), and the workarounds we used to get a nearly fully working iOS build with Capacitor.
Android has not been tested yet.

Context

We’re building a mobile-enabled Meteor + React app that enables route planning based on dynamic local road traffic restrictions. We needed native features like background geolocation, and began exploring mobile wrappers: Cordova and Capacitor.

While our main goal was to get our own app working, we’re also sharing this here to document the path, surface current limitations, and hopefully pave the way for future integration between Meteor and Capacitor.

We’re hoping this post can:

Attempt 1

We first tried building a native app with Cordova following the Meteor documentation. The setup worked, but the app launched to a white screen with the following error: (privateGet.values is not a function).

We traced the issue to @tanstack/react-query@5, which wasn’t being compiled properly. Downgrading to v4 resolved it, but downgrading was not an option for us nor was changing library, due to other constraints.

Yet we found no way of making it work within Cordova environment. We thought of raising an issue about it, but ultimately decided to explore Capacitor instead, especially after seeing indications that Meteor’s future may lean toward Capacitor.

:construction: Attempt 2 – Capacitor

Capacitor requires a static index.html to work, but Meteor streams HTML at runtime using the boilerplate-generatorpackage — no actual file exists on disk. Cordova’s build does generate an index.html, so we copied that into web.browser, removed all “cordova” occurrences, and launched Capacitor.

That worked to launch the app, but Meteor wasn’t initializing properly. The key issue was incorrect/missing properties in __meteor_runtime_config__, specifically regarding HRM versions, and settings.

We manually merged the missing properties from a working web session, keeping ROOT_URL , ROOT_URL_PATH_PREFIX , DDP_DEFAULT_CONNECTION_URL, needed by the remote client to tell were the actual meteor server is. Once added, the Meteor client ran normally — until…

Modern vs Legacy Client Detection

We were again blocked by the original white screen issue caused by @tanstack/react-query@5.

After debugging Meteor’s build process, we discovered the iOS WebView user agent was seen as: mobileSafari 0.0.0

Meteor marked the client as legacy, and the modern bundle (with correct polyfills and transpilation) wasn’t loaded.

To fix this, we overrode the WebView user agent in iOS to include a valid version string. You can also use appendUserAgent or overrideUserAgent in the Capacitor config, but that applies globally — so we took a more granular approach and dynamically injected the iOS version based on device properties in the native code of Capacitor.

Here is the code injected in the `AppDelegate.swift` file
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    		// Override point for customization after application launch.
    		setCustomUserAgent()
    		// [capacitor-background-fetch]
    		
        return true
    }
    
    private func setCustomUserAgent() {
    		// Wait for WebView to initialize
    		DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 
    		    if let webView = self.findWebView() {
            // Construct the User Agent manually
            // Format iOS version
    		        let iosVersion = UIDevice.current.systemVersion
    		        // Device model (e.g., iPhone)
                let model = UIDevice.current.model
                // Example build number (this can be dynamic if needed)
                let buildNumber = "15E148"
                let newUserAgent = "Mozilla/5.0 (\(model); CPU \(model) OS \(iosVersion.replacingOccurrences(of: ".", with: "_")) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/\(buildNumber) Version/\(iosVersion)"
                    
                 webView.customUserAgent = newUserAgent
            }
        }
    }
        
    private func findWebView() -> WKWebView? {
        guard let keyWindow = UIApplication.shared.connectedScenes
            .compactMap({ $0 as? UIWindowScene })
            .flatMap({ $0.windows })
            .first(where: { $0.isKeyWindow }),
              let rootVC = keyWindow.rootViewController as? CAPBridgeViewController else {
            return nil
        }
        return rootVC.webView
    }

Assets loading & HMR

At this point, our Meteor app was running in Capacitor on iOS. A few remaining issues:

  • Static assets loading:
    • cordova-plugin-meteor-webapp uses program.json as a manifest to load assets and handle hot-code push.
    • Capacitor doesn’t support this, so we added helpers directly in the iOS native code to recover assets from that program.json file. We did not look into hot-code push.
Here is the code injected in the `WebViewAssetHandler.swift` file
open func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
    let startPath: String
    let url = urlSchemeTask.request.url!
    var stringToLoad = url.path
    let localUrl = URL.init(string: url.absoluteString)!
  
    if url.path.starts(with: CapacitorBridge.httpInterceptorStartIdentifier) {
        handleCapacitorHttpRequest(urlSchemeTask, localUrl, false)
        return
    }
  
    if stringToLoad.starts(with: CapacitorBridge.fileStartIdentifier) {
        startPath = stringToLoad.replacingOccurrences(of: CapacitorBridge.fileStartIdentifier, with: "")
    } else {
        // Look up correct path from program.json
        if let resolvedPath = resolveAssetPath(stringToLoad) {
            stringToLoad = "/" + resolvedPath
        }
        
        startPath = router.route(for: stringToLoad)
    }
  
    let fileUrl = URL.init(fileURLWithPath: startPath)
  
				//capacitorJs part
    do {
        var data = Data()
        let mimeType = mimeTypeForExtension(pathExtension: url.pathExtension)
        var headers =  [
            "Content-Type": mimeType,
            "Cache-Control": "no-cache"
        ]
  
        // if using live reload, then set CORS headers
        if isUsingLiveReload(localUrl) {
            headers["Access-Control-Allow-Origin"] = self.serverUrl?.absoluteString
            headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS, TRACE"
        }
  
        if let rangeString = urlSchemeTask.request.value(forHTTPHeaderField: "Range"),
           let totalSize = try fileUrl.resourceValues(forKeys: [.fileSizeKey]).fileSize,
           isMediaExtension(pathExtension: url.pathExtension) {
            let fileHandle = try FileHandle(forReadingFrom: fileUrl)
            let parts = rangeString.components(separatedBy: "=")
            let streamParts = parts[1].components(separatedBy: "-")
            let fromRange = Int(streamParts[0]) ?? 0
            var toRange = totalSize - 1
            if streamParts.count > 1 {
                toRange = Int(streamParts[1]) ?? toRange
            }
            let rangeLength = toRange - fromRange + 1
            try fileHandle.seek(toOffset: UInt64(fromRange))
            data = fileHandle.readData(ofLength: rangeLength)
            headers["Accept-Ranges"] = "bytes"
            headers["Content-Range"] = "bytes \(fromRange)-\(toRange)/\(totalSize)"
            headers["Content-Length"] = String(data.count)
            let response = HTTPURLResponse(url: localUrl, statusCode: 206, httpVersion: nil, headerFields: headers)
            urlSchemeTask.didReceive(response!)
            try fileHandle.close()
        } else {
            if !stringToLoad.contains("cordova.js") {
                if isMediaExtension(pathExtension: url.pathExtension) {
                    data = try Data(contentsOf: fileUrl, options: Data.ReadingOptions.mappedIfSafe)
                } else {
                    data = try Data(contentsOf: fileUrl)
                }
            }
            let urlResponse = URLResponse(url: localUrl, mimeType: mimeType, expectedContentLength: data.count, textEncodingName: nil)
            let httpResponse = HTTPURLResponse(url: localUrl, statusCode: 200, httpVersion: nil, headerFields: headers)
            if isMediaExtension(pathExtension: url.pathExtension) {
                urlSchemeTask.didReceive(urlResponse)
            } else {
                urlSchemeTask.didReceive(httpResponse!)
            }
        }
        urlSchemeTask.didReceive(data)
    } catch let error as NSError {
        urlSchemeTask.didFailWithError(error)
        return
    }
    urlSchemeTask.didFinish()
}    
  
// Load program.json (this should be done once and cached)
func loadProgramJson() -> [String: Any]? {
    if let path = Bundle.main.path(forResource: "public/program", ofType: "json") {
        do {
            let data = try Data(contentsOf: URL(fileURLWithPath: path))
            let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
            return json
        } catch {
            CAPLog.print("Error loading program.json: \(error)")
        }
    }
  
    return nil
}
  
// Function to resolve asset path
func resolveAssetPath(_ requestPath: String) -> String? {
    guard let programJson = loadProgramJson(),
          let assets = programJson["manifest"] as? [[String: Any]] else {
        return nil
    }
    for asset in assets {
        if let url = asset["url"] as? String, requestPath == url {
            return asset["path"] as? String
        }
    }
    return nil
}
  • Hot code reload:
    • During development, updating Meteor erases the copied index.html (generated from Cordova build and that we needed) and changes HMR-related values.
    • We wrote a post-build sync script to re-copy files and patch things back after each change. This is indeed a hack and should be handled by Meteor build tool when integrating with capacitor.

What’s left ?

The investigation was mainly done on iOS. We did not investigate assets loading in Android. We’re also unsure about the modern issued in Android.

Also, we are not sure yet how different it would be with a production build. Deploying a minor bugfix in production should probably not force you to release a new mobile version of your app. Theoretically, Live update should allow to update the app without a new release.

What We’d Love Feedback On

  • Is there a more idiomatic way within the Meteor build pipeline to expose __meteor_runtime_config__ to a Capacitor wrapper?
  • Are there internal hooks or config options in the boilerplate-generator or another packages that could make generating a standalone index.html more stable or automated?
  • Has anyone looked into alternatives to cordova-plugin-meteor-webapp for asset serving and HCP that could work with Capacitor?
  • More generally — if you know the Meteor build/runtime internals, we’d love your take on how this kind of hybrid setup could be better supported.

We’re happy to provide more detail on our patches if it can help move official support forward. Let us know what you think or if there are better approaches!

4 Likes

This is amazing and a huge step forward. Though, given the swift code you shared. I’m led to believe this was only possible due to your deep IOS/Swift knowledge.

@nachocodoner is already running Capacitor in a hobby project of his, so he’d definitely be interested.

I’ve been toying with the idea of wrapping a Meteor+React app in Capacitor for similar reasons, but didn’t expect so many quirks with the build process and client detection. The meteor runtime config patching makes sense now that you mention it, and I wouldn’t have thought to check the iOS user agent behavior.

As @harry97 mentioned, one of the things I’ve worked on in the past is integrating Capacitor into my Meteor apps. I work part-time on Meteor core, and the rest of my time is dedicated to creative experimentation and inovation, and hobby projects.

I’d like to take this chance to share more about what I’ve done. Maybe it’ll help you complement or rethink your current approach.

Capacitor can work independently

First, considering how tightly Meteor is integrated with its tooling, my main goal with Capacitor integration was keeping it independent. The idea was to let the Capacitor app evolve on its own, rather than being fully tied to Meteor’s setup.

If you’ve worked on a standalone Capacitor project, you’ll know that you can wrap any existing web app as a native app. Running npx cap add android or npx cap add ios sets up a native project inside your app. When you run npx cap sync, Capacitor compiles whatever is in your webDir config into the Android and iOS projects.

So essentially, any web project with an index.html and referenced assets can be wrapped using CapacitorJS.

Reusing the Meteor Cordova build

When you run meteor run android or meteor run ios in dev mode, Meteor produces Cordova-ready assets in .meteor/local/build/programs/web.cordova. The same applies when running a production build with meteor build but in a different folder.

Inside that folder you’ll find all necessary assets: your public/ files, compiled packages/, app.js code, etc, basically the full Meteor app structure ready for native environments. The only missing file is index.html, which links them.

As you mentioned, that file is dynamically generated via boilerplate-generator. But you can fetch it with a simple curl from http://localhost:3000/__cordova/index.html while your app is running locally. Drop it into your Capacitor webDir, along with the other files.

At first, it’ll break. That’s expected, since paths like __cordova/* won’t resolve. But if you fix those paths to match your webDir structure, the app will start loading.

Once you’ve done that, run your app with npx cap run android or npx cap run ios. You may face some runtime issues. One common one is Meteor code referencing WebAppLocalServer, which doesn’t work since the cordova-plugin-meteor-webapp isn’t compatible with Capacitor. You can patch WebAppLocalServer with a no-op in index.html.

Other fixes might be needed depending on your app. Simpler projects with fewer Cordova dependencies will be easier. Apps that use Cordova dependencies via Meteor’s cordova-plugins file must migrate to full CapacitorJS integration, which supports those plugins, though you’ll need to add them to your project’s package.json instead and ensure its compatibility. But I recommend finding and replacing to the official Capacitor plugin alternative.

Also, configure capacitor.config.json to point to your Meteor server during development, and ROOT_URL in production.

With this setup, you should see your app running in the Capacitor emulator.

Automation is key

Once this works, it’s a big step. But for proper development experience, automation is critical.

You need to run Meteor in Cordova mode. Then, use a watcher to monitor .meteor/local/build/programs/web.cordova, copy the updated files into your webDir, fetch and patch the index.html, and let Capacitor sync changes.

You’ll also want a script to prepare the Capacitor app for production, based on the output of meteor build.

What’s missing?

The app will behave like a Cordova app, since it’s using the same build and server runtime.

You might ask about HCP. Since cordova-plugin-meteor-webapp doesn’t work with Capacitor, you’ll need another solution. CapacitorJS provides its own local server, so Meteor’s is no longer required or maintained. The good news is you can skip HCP or use a Capacitor-native approach. I personally use a manual, self-hosted HCP setup with @capgo/capacitor-updater, but they also offer a hosted service.

As for production, once you’ve built your Android and iOS projects, use Android Studio and Xcode to distribute them, same as with Meteor Cordova apps (docs here).


I’ve been using CapacitorJS for long time. I’ve used plugins like camera, paywall, barcode scanner, HCP, and others without issues. It’s fully compatible with both Android and iOS.

Early on I faced problems due to Node 14 limits in Meteor 2. To use newer Capacitor versions (requiring latest Node), I ran them on a standalone Node setup. With Meteor 3 and its updated Node, this won’t be a problem anymore.

As you know, CapacitorJS is on our roadmap. I’d be happy to help automate this approach at the core level. Right now, our focus is on speeding up the bundler and integrating a modern one, unlocking features like tree shaking, exports in package.json, ESM, plugins, and more. I’ve already explored this in my hobby project, and now it’s time to bring it in. Once that’s done, the CapacitorJS experience will benefit too, especially considering how slow the current Cordova build process is.

Hopefully this inspires some of you to experiment or extend your current setup.

I have a working approach and would like to automate it for the core. Fortunately, Meteor 3.3 will include core changes for CapacitorJS, so I can experiment with the approach explained here via a core package sooner than expected.

5 Likes