Lee Harden
All posts
nextjscapacitoriosmobileapp-store

Shipping a Next.js App to the iOS App Store with Capacitor

How to take a Next.js web application, wrap it with Capacitor, and ship it to the iOS App Store — with full access to the device APIs that distinguish a real app from a glorified browser tab.

7 min readby Lee Harden

A Next.js application is an excellent place to write the user-facing layer of a product. The framework is mature, the deployment story is straightforward, and the full ecosystem of React libraries is available. The one place a Next.js application is awkward to deliver, until recently, was the iOS App Store.

Capacitor closes that gap. It is a thin native runtime, maintained by the Ionic team, that wraps a web application in a real iOS — or Android — shell. The web layer continues to be Next.js, served either as static files inside the binary or as a remote URL the wrapper points at. The native layer exposes a long list of device APIs through a plugin system that the JavaScript inside the webview can call directly.

The result is a real iOS application. It is distributable through the App Store, eligible for TestFlight, addressable by push notifications, capable of using the camera, the GPS, biometrics, and the secure enclave — and it shares essentially all of its product code with the web application running on the same domain.

I have used this approach in production. The setup is small and the trade-offs are interesting in both directions.

The two architectural choices

Capacitor accepts the web layer in one of two shapes. Picking between them is the first decision, and it sets the tone for everything else.

Static export bundled into the app binary

Configure Next.js for a fully static export. The build produces a directory of HTML, JavaScript, and CSS that gets copied into the iOS project at build time. The webview inside the Capacitor shell points at the local files. The app works offline, ships its UI alongside the binary, and never requires a network round-trip to render.

The cost is reach. Server-side rendering, server actions, route handlers, image optimization, and anything else that requires a Node runtime is gone. Updates require an App Store resubmission. The binary grows with every static asset.

Remote-loading from a deployed Next.js application

Configure the Capacitor shell to load a remote URL — typically the same Cloud Run, Vercel, or other deployment that serves the web product — inside its webview. The full Next.js feature set is available, because the application is running on a real server. Updates ship instantly to every installed device the next time the app is opened. The binary is small.

The cost is connectivity. The application requires a working network on first load, and the cold-start latency of the remote server becomes part of the user's launch experience. Genuine offline behavior must be designed deliberately, usually with a service worker.

For an actively iterating product, the remote-loading shape tends to win. The ability to push a fix to production and have every iOS user pick it up by the next session, without a one-week App Store review cycle in the middle, is hard to give up once experienced.

What Capacitor actually does

The Capacitor shell is structurally simple. It is an iOS application whose root view controller is a WKWebView configured to load either a local bundle or a remote URL, plus a small bridge that makes native APIs callable from JavaScript.

The bridge is the interesting part. A Capacitor plugin is a thin Swift class that exposes methods to the webview. The JavaScript side imports a typed wrapper:

import { Camera, CameraResultType } from "@capacitor/camera"

const photo = await Camera.getPhoto({
  resultType: CameraResultType.Uri,
  quality: 85,
})

Behind that call, the bridge dispatches into Swift, the operating system presents the native camera UI, the user takes the photo, and the result returns to JavaScript as a typed value. The web code does not know it is running inside an app; it just sees a Promise that resolves with a file URI.

This pattern repeats across the entire surface of iOS. Geolocation, push notifications, biometrics, haptics, the share sheet, secure storage backed by the keychain, motion sensors, the file system, deep links, the status bar — each is a plugin that turns a native API into a JavaScript Promise. Plugins maintained by the Capacitor core team cover most of the common cases, and writing a custom one for a less common API is on the order of a few dozen lines of Swift.

The Apple Guideline 4.2 question

This is the part of the conversation that comes up first and matters most for App Store approval. Apple's review guideline 4.2 — "Minimum Functionality" — is the rule used to reject apps that are functionally identical to a website wrapped in a webview. A reviewer who opens an app, sees only browser content, and notices that the same content is freely available at the equivalent URL will reject the submission.

The way to satisfy 4.2 is to do something the website cannot. Capacitor is purpose-built for exactly this. An app that uses biometric authentication on launch, captures photos with the device camera, receives push notifications from a backend, or reads sensor data is unambiguously a real app, even if every pixel of its UI is rendered by the same Next.js codebase that powers the website.

In practice, picking one or two native integrations early in the design — a useful one, not a token one — is enough to clear the bar. The reviewer wants to see that the iOS version is an iOS version, not a frame around a URL.

The submission flow

Once the wrapper is in place, the rest of the workflow is the standard iOS workflow.

  1. Open the generated Xcode project. Capacitor produces a real ios/App/App.xcworkspace that Xcode treats like any other Swift project.
  2. Configure the bundle identifier, the signing team, and the entitlements. Capabilities — push notifications, sign-in-with-Apple, associated domains for deep links — are added in the same Xcode panel any iOS developer uses.
  3. Archive and upload to App Store Connect. TestFlight builds become available within minutes for internal testers.
  4. Submit for review. Apple's first-pass automated review is fast; the human review takes one to three days for a new app.

There is no separate "Capacitor app" track. From Apple's side, the application is indistinguishable from any other native iOS app, because that is what it is.

Where the pattern works less well

Two cases push back on this approach.

Performance-sensitive interfaces. An interface that depends on sixty-frames-per-second native animations, complex gesture recognizers, or heavy on-device computation will hit the limits of a webview. The webview is fast for typical product UI; it is not the right tool for a video editor, a real-time drawing canvas, or a game.

Strict offline-first products. A product that must work entirely offline, with full data sync when the network returns, is solvable with the static-export shape and a service worker, but it is more work than a remote-loading deployment. If offline-first is a hard requirement, account for the cost up front rather than bolting it on later.

For most product categories — the ones where the iOS version exists to put a familiar web product in the user's pocket with a few well-chosen native enhancements — the trade-off is favorable.

What the pattern actually buys

The win is structural. One codebase, written in the framework the team already uses, ships to the web and to the iOS App Store at the same time. A bug fix on Tuesday afternoon reaches every web user instantly and every iOS user on their next launch. Native APIs are an npm install and a Swift plugin away, not a second team writing a parallel application in another language.

The cost is small: a Capacitor configuration, an Xcode project, and the rituals of App Store signing and submission. The capability gap between a Next.js web application and a native iOS application narrows from a chasm to a step.

For a small team shipping a product on multiple surfaces, that step is the difference between having an iOS app and not.