---
title: How to Build an Apple Watch App for Your Capacitor App
description: A Capacitor Apple Watch app pairs a native SwiftUI watch target with your existing iOS app. Here is the full setup, from Xcode target to App Store.
date:
  created: 2026-09-24
  updated: 2026-09-24
authors:
  - robingenz
categories:
  - Capacitor
  - Guides
  - SDKs
links:
  - Capacitor Watch: sdks/capacitor/watch.md
faq: true
---

# How to Build an Apple Watch App for Your Capacitor App

Yes, you can build an Apple Watch app for an Ionic or Capacitor app, and the part that runs on the wrist is native SwiftUI. A Capacitor Apple Watch app is two apps in one Xcode project: the iPhone app your web code already runs in, and a watch app target written in Swift. Data moves between them over Apple's Watch Connectivity framework, which the [Capacitor Watch plugin](../../sdks/capacitor/watch.md) exposes as a TypeScript API on the phone and as a Swift Package on the watch. This guide covers the setup end to end: creating the watch target, adding the SDK, the bundle identifier rule that decides whether the two apps ever find each other, the three ways to move data, and where the Simulator stops being useful.

<!-- more -->

<div class="capawesome-z29o10a">
  <a href="https://capawesome.io/" target="_blank">
    <img alt="Build and deploy your Capacitor app with Capawesome Cloud" src="https://capawesome.io/assets/banners/cloud-build-and-deploy-capacitor-apps.png?t=1" />
  </a>
</div>

**Key takeaways:**

- A Capacitor Apple Watch app is two apps in one Xcode project, the Capacitor iOS app and a native SwiftUI watch target that Capacitor does not render.
- The Capacitor Watch plugin bridges them over Watch Connectivity with three channels: `sendMessage(...)` for live delivery, `updateState(...)` for latest-wins state, `transferUserInfo(...)` for queued transfers.
- The watchOS SDK is a local Swift Package at `node_modules/@capawesome-team/capacitor-watch/sdks/watchos`, added as the `CapawesomeWatchSDK` product to the watch app target only.
- The watch app's bundle identifier must be prefixed with the iOS app's bundle identifier (for example `com.example.app.watchkitapp`), which Xcode derives from the target template.
- Apple warns that the Simulator does not support `transferUserInfo(_:)` and that Watch Connectivity data transfers should always be tested on paired devices.

## Rendering vs. communication

An Apple Watch app next to a Capacitor app has two halves that never mix. Your Capacitor web view stays on the iPhone, and the watch screens are a native SwiftUI target compiled into the same Xcode project. Between them sits Apple's [Watch Connectivity](https://developer.apple.com/documentation/watchconnectivity){:target="_blank"} framework, which Apple describes as "two-way communication between an iOS app and its paired watchOS app", with the system taking responsibility for the transmission once your app hands data over.

The [Capacitor Watch plugin](../../sdks/capacitor/watch.md) covers both ends of that connection. On the phone it activates the `WCSession` when it loads and exposes the three transfer channels as promise-based TypeScript. On the watch it ships `CapawesomeWatchSDK`, a Swift Package whose `ObservableObject` binds straight into a SwiftUI view. It does not draw the watch UI, which is why most of the Swift in this guide is ordinary watchOS development.

There is an official plugin too, with a different goal. [ionic-team/CapacitorWatch](https://github.com/ionic-team/CapacitorWatch){:target="_blank"} (`@capacitor/watch`) is iOS only and ships under the CapacitorLABS label, and its README states that the project is experimental and "Support is not provided". Its approach is to define the watch UI in web code: you pass a newline-delimited string of `Text` and `Button` components to `updateWatchUI({ watchUI })`, push values with `updateWatchData({ data })`, and receive taps through a single `addListener('runCommand')` event. Setup includes `AppDelegate` edits plus the Background Modes and Push Notifications capabilities on the iOS target. If your watch screens fit those two component types and you would rather write them in TypeScript, that plugin is the shorter path. The rest of this guide takes the native route.

## What you need

Five things before the first build:

- A Mac with a current Xcode and a Capacitor app that already has the iOS platform added.
- An iPhone with a paired Apple Watch. Two paired simulators cover part of the work, with the caveats in the testing section below.
- An Apple Developer account, because the watch app is signed and distributed together with the iOS app.
- A [Capawesome Insiders](../../insiders/index.md) subscription. The Capacitor Watch plugin is a paid plugin and installs from the Capawesome npm registry with the license key that comes with it.
- Enough Swift and SwiftUI to build a few screens. The phone side stays TypeScript.

## Phone-side setup

The iPhone half of the bridge is the plugin and nothing else. To install the Capacitor Watch plugin, refer to the [Installation](../../sdks/capacitor/watch.md#installation) section of the plugin documentation, which covers the registry configuration and the license key.

Once `npx cap sync` has run, iOS needs no further configuration. The plugin activates the `WCSession` when it loads and handles the session delegate callbacks internally, so your `AppDelegate` stays untouched. Apple requires a delegate to be assigned before the session is activated, and that ordering is the plugin's responsibility rather than yours. No capability, entitlement, or background mode has to be enabled for Watch Connectivity. The `capability` option in `capacitor.config.ts` is Android only, where it names the Data Layer capability string.

## Adding the watch target

The watch app lives inside your existing Xcode project as a second target, alongside the `App` target that Capacitor generates. Three steps get it building against the plugin's watch SDK.

### Create the target

Open `ios/App/App.xcodeproj`, or `ios/App/App.xcworkspace` if your app uses CocoaPods. Choose **File > New > Target…**, switch to the **watchOS** tab and pick the **Watch App for Existing iOS App** template. Enter a product name such as `watch` and select **SwiftUI** as the interface.

If the dialog offers no companion app to attach to, you either opened **File > New > Project…** instead of **File > New > Target…**, or the **Project** dropdown at the bottom of the dialog points at a different project.

### Add the Swift Package

The watch SDK ships inside the npm package, so there is nothing extra to download. Choose **File > Add Package Dependencies… > Add Local…**, select the `node_modules/@capawesome-team/capacitor-watch/sdks/watchos` folder, and add the `CapawesomeWatchSDK` product to your watch app target. The plugin documentation is explicit about that last part: the product belongs on the watch app target, not on the iOS app target.

That local reference is resolved relative to the Xcode project, which means `node_modules` has to exist before you open the project, and your CI has to run the install step before it builds the iOS scheme. For a working starting point, the plugin repository contains a minimal watch app in [`example/watchos`](https://github.com/capawesome-team/capacitor-plugins/tree/main/packages/watch/example/watchos){:target="_blank"}. When you add those files, enable **Copy items if needed**, tick only the watch app as target, and skip the bridging header, since the sources are pure Swift.

### The bundle ID rule

Bundle identifiers are what tie the two apps together, and a later rename of the iOS app breaks the link. The watch app's bundle identifier must be prefixed with the iOS app's bundle identifier, for example `com.example.app` and `com.example.app.watchkitapp`. The plugin's watchOS setup states the rule, and Apple's archived [watchOS key reference](https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/watchOSKeys.html){:target="_blank"} spells it out for `WKAppBundleIdentifier`: "Apart from the addition of the `.watchkitapp` string, the bundle identifier of the Watch app must match the bundle identifier of the iOS app." The current Apple documentation for setting up a watchOS project no longer restates it, because the target template derives the identifier for you.

The key that links the watch app back to the phone app today is [`WKCompanionAppBundleIdentifier`](https://developer.apple.com/documentation/bundleresources/information-property-list/wkcompanionappbundleidentifier){:target="_blank"}, which Apple documents as "the bundle ID of the watchOS app's companion iOS app" and whose value "should be the same as the iOS app's `CFBundleIdentifier`". Xcode writes it into the watch app's `Info.plist` when you create the target from the template.

!!! warning "Check both identifiers after a rename"

    If you change the iOS bundle identifier later, update the watch app's identifier and `WKCompanionAppBundleIdentifier` in the same commit, and keep both targets on the same development team. Apple's archived reference states the consequence: "The system does not launch a Watch app whose bundle identifier does not match the bundle identifier of its WatchKit extension or iOS app."

## Building the watch side

The watch side of the plugin is a single shared object. `CapawesomeWatch.shared` is an `ObservableObject`, so `@ObservedObject` gives a SwiftUI view the connection state without any glue code. It has to be activated once before anything is sent or received, and the example app does that in the `App` struct:

```swift title="ExampleWatchApp.swift"
import SwiftUI
import CapawesomeWatchSDK

@main
struct ExampleWatchApp: App {
    init() {
        CapawesomeWatch.shared.activate()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

Calling `watch.activate()` from a view's `.onAppear` works as well, which is the form the plugin documentation shows. Everything else on the watch is receiving and sending.

### Receive on the watch

Incoming data arrives through two closure properties that you assign once, typically in `.onAppear`. The published `reachable` property drives the UI in the same view:

```swift title="ContentView.swift"
import SwiftUI
import CapawesomeWatchSDK

struct ContentView: View {
    @ObservedObject private var watch = CapawesomeWatch.shared
    @State private var lastMessage = "-"

    var body: some View {
        VStack(spacing: 8) {
            Text(watch.reachable ? "Reachable" : "Not reachable")
                .font(.footnote)
            Text("Last message: \(lastMessage)")
                .font(.footnote)
        }
        .onAppear {
            watch.onMessageReceived = { data, reply in
                lastMessage = String(describing: data)
                reply?(["text": "Hello from the watch!"])
            }
            watch.onUserInfoReceived = { data in
                lastMessage = String(describing: data)
            }
        }
    }
}
```

The `reply` closure in `onMessageReceived` is optional and corresponds to the phone's `expectsReply` option, so call it as `reply?(...)` and the watch responds only when the phone asked for it.

### Send from the watch

Sending goes through the same object, one method per channel. Each of these fits into a SwiftUI `Button` action:

```swift
Button("Send Message") {
    watch.sendMessage(["text": "Hello from the watch!"])
}
Button("Send Message (Reply)") {
    watch.sendMessage(["text": "Ping"], replyHandler: { reply in
        lastMessage = String(describing: reply)
    })
}
Button("Transfer User Info") {
    watch.transferUserInfo(["sentAt": Date().timeIntervalSince1970])
}
Button("Update State") {
    try? watch.updateState(["counter": Int.random(in: 0...100)])
}
```

Only `updateState(_:)` throws, which mirrors Apple's [`updateApplicationContext(_:)`](https://developer.apple.com/documentation/watchconnectivity/wcsession/updateapplicationcontext(_:)){:target="_blank"} underneath it, so `try?` in a button action keeps the call site readable. The rest of the watch app, the navigation, complications, and layout, is plain watchOS work with nothing plugin-specific about it.

## Talking from Capacitor

On the phone the whole bridge is one import and a set of promise-based calls, identical to the ones a Wear OS build would use.

### Check the connection

[`getConnectionInfo()`](../../sdks/capacitor/watch.md#getconnectioninfo) answers the three questions worth asking before you show any watch-related UI:

```typescript
import { Watch } from '@capawesome-team/capacitor-watch';

const { paired, watchAppInstalled, reachable } = await Watch.getConnectionInfo();
```

On iOS all three carry real values, which is the one place the API behaves differently from Android, where `paired` is always `null`. They map onto `WCSession`'s [`isPaired`](https://developer.apple.com/documentation/watchconnectivity/wcsession/ispaired){:target="_blank"}, `isWatchAppInstalled` and [`isReachable`](https://developer.apple.com/documentation/watchconnectivity/wcsession/isreachable){:target="_blank"}. A common result on a healthy setup is `{ paired: true, watchAppInstalled: true, reachable: false }`: the watch is there and has your app, but the watch app is not running, so the live channel is closed and `sendMessage(...)` would reject. Apple also notes that an iPhone can be paired with more than one Apple Watch while only the active one communicates with your app, so these values describe the currently active watch.

### Send and receive

Each channel is a single call, and the difference is what it promises about delivery:

```typescript
await Watch.sendMessage({ data: { text: 'Hello from the phone!' } });

const { reply } = await Watch.sendMessage({
  data: { text: 'Ping' },
  expectsReply: true,
});

await Watch.updateState({ data: { counter: 42 } });

await Watch.transferUserInfo({ data: { sentAt: Date.now() } });
```

Use [`sendMessage(...)`](../../sdks/capacitor/watch.md#sendmessage) with `expectsReply: true` when you want the watch's answer in the same promise, [`updateState(...)`](../../sdks/capacitor/watch.md#updatestate) when only the newest value is interesting, and [`transferUserInfo(...)`](../../sdks/capacitor/watch.md#transferuserinfo) when no item may be dropped. Data coming back from the watch arrives through listeners, one per channel plus one for reachability:

```typescript
await Watch.addListener('messageReceived', async event => {
  if (event.messageId) {
    await Watch.replyToMessage({
      data: { text: 'Hello back!' },
      messageId: event.messageId,
    });
  }
});
await Watch.addListener('reachabilityChange', event => console.log(event.reachable));
await Watch.addListener('stateReceived', event => console.log(event.data));
await Watch.addListener('userInfoReceived', event => console.log(event.data));
```

A `messageId` on the [`messageReceived`](../../sdks/capacitor/watch.md#addlistenermessagereceived-) event means the watch used `sendMessage(_:replyHandler:)` and is waiting, which [`replyToMessage(...)`](../../sdks/capacitor/watch.md#replytomessage) answers. If you care about the current state rather than the event stream, [`getReceivedState()`](../../sdks/capacitor/watch.md#getreceivedstate) returns the last state the watch sent at any time, including after an app restart.

## Which channel to use

The three methods are thin wrappers over three `WCSession` APIs, and the guarantees are Apple's own:

| Method                  | Semantics             | iOS API                    | Guarantee                                      |
| ----------------------- | --------------------- | -------------------------- | ---------------------------------------------- |
| `sendMessage(...)`      | Live, optional reply  | `WCSession.sendMessage`    | Requires reachability, not queued              |
| `updateState(...)`      | Latest state wins     | `updateApplicationContext` | Replaces undelivered state, survives restarts  |
| `transferUserInfo(...)` | Queued, all delivered | `transferUserInfo`         | Delivered in the order they were queued        |

One asymmetry shapes which side should start a conversation. Apple's documentation for [`sendMessage(_:replyHandler:errorHandler:)`](https://developer.apple.com/documentation/watchconnectivity/wcsession/sendmessage(_:replyhandler:errorhandler:)){:target="_blank"} says that a message sent from the watch "wakes up the corresponding iOS app in the background and makes it reachable", while "calling this method from your iOS app does not wake up the corresponding WatchKit extension". A phone-to-watch message therefore only lands while the watch app is already running. For the other direction the phone can be closed, which makes the watch the natural initiator of a request/reply round trip. The state you send with `updateState(...)` is persisted by the system and survives restarts on both sides, and queued transfers arrive on iOS in the order they were sent. The announcement post has the same comparison [with the Android column](./announcing-the-capacitor-watch-plugin.md#how-the-three-channels-compare) if you are building for both platforms.

## Limits on watchOS

Watch Connectivity has four limits that shape the data flow, and all four are easier to design around than to retrofit.

- **Payload size**: the plugin documentation puts the cap at about 100 KB per payload on every channel. Apple documents the failure mode as `WCError.Code.payloadTooLarge`, "an attempt to send an item that exceeds the maximum size limit", without publishing a byte number. Send a record id or URL and fetch the content from your backend.
- **Payload types**: payloads must be property list compatible and `null` values are not supported. Apple's matching error code is `payloadUnsupportedTypes`, raised when "a dictionary contains nonproperty list types".
- **Background reception**: data that arrives while your app is closed is held in memory until your listeners register. On iOS, events that were never consumed are lost if the app is terminated in between, which the announcement post covers in [what happens while your app is closed](./announcing-the-capacitor-watch-plugin.md#what-happens-while-your-app-is-closed).
- **Pairing**: an Apple Watch pairs only with an iPhone. A Wear OS watch paired to an iPhone cannot reach your app at all, and no plugin can change that.

A watch app built this way is a dependent watch app: the iPhone app is where its data comes from. Apple's guide on [creating independent watchOS apps](https://developer.apple.com/documentation/watchos-apps/creating-independent-watchos-apps){:target="_blank"} states that independent apps "can't rely on the WatchConnectivity framework to transfer data or files from a companion iOS app", and Apple's advice for keeping watch content fresh is to treat Watch Connectivity "as an opportunistic optimization, rather than the primary means of supplying fresh data". If your watch app must work with the iPhone out of range, plan a second data source such as CloudKit or your own API.

## Testing in the Simulator

The Simulator covers only part of this bridge, and Apple says so in the API reference. The warning on [`transferUserInfo(_:)`](https://developer.apple.com/documentation/watchconnectivity/wcsession/transferuserinfo(_:)){:target="_blank"} reads: "Always test Watch Connectivity data transfers on paired devices. The Simulator app doesn't support the `transferUserInfo(_:)` method." The same warning appears on `transferFile(_:metadata:)`. Build and iterate on your SwiftUI screens in the Simulator, and verify delivery behavior on a paired iPhone and Apple Watch before you ship.

Simulators also have to be paired, because an unpaired watch simulator has no phone to connect to. The window that manages pairing is **Window > Devices and Simulators** in earlier Xcode versions and **Device Hub**, the separate app that ships with Xcode 27. The command line does the same job and is the part you can script:

```bash
xcrun simctl list devices
xcrun simctl pair <watch-udid> <phone-udid>
```

With the pair in place, run the iOS app on the phone simulator and the watch app scheme on its paired watch simulator. Both apps have to be installed and running before the live channel opens, and Xcode narrows the run destinations to watchOS devices as soon as the selected scheme contains the watch app. Once both are up, the connection check from the previous section tells you where you stand: `watchAppInstalled: false` means the watch app never made it onto that watch, and `reachable: false` with everything else true means the watch app is not in the foreground.

## Shipping the watch app

The watch app is not a separate App Store listing. App Store Connect's guidance is to "create an iOS app in Xcode that includes a watchOS counterpart" and to "upload both apps to App Store Connect from the same Xcode project", as documented under [adding platforms](https://developer.apple.com/help/app-store-connect/create-an-app-record/add-platforms/){:target="_blank"}. The watchOS screenshots and metadata then hang off the same app record, under the Apple Watch tab in Previews and Screenshots.

Signing follows from the second bundle identifier. Because the watch app carries its own, it also needs its own App ID and provisioning profile, and an explicit App ID must match the bundle ID entered in the target's settings. With "Automatically manage signing" enabled, Xcode registers and creates both for you. If the iOS build runs on a hosted machine rather than your Mac, [Capawesome Cloud](https://capawesome.io/){:target="_blank"} Native Builds is the managed option for that step.

## FAQ

### Can you build an Apple Watch app with Ionic?

Yes. Ionic and Capacitor render the phone UI in a web view, and the watch app is a native SwiftUI target in the same Xcode project. The Capacitor Watch plugin connects the two over Watch Connectivity, and nothing about the setup changes because the phone UI is Ionic.

### Do I have to write Swift for the watch app?

For the watch UI, yes. The screens on the watch are SwiftUI, and the plugin's watchOS SDK is a Swift Package you add to that target. Everything on the phone stays TypeScript, including every call to the watch and every listener.

### Does the Capacitor Watch plugin render the watch UI?

No, by design. The plugin is a communication bridge, so your watch screens are built natively with the full watchOS toolkit instead of a restricted component set. The [announcement post](./announcing-the-capacitor-watch-plugin.md#watchos-a-swift-package-for-swiftui) shows the smallest SwiftUI view that connects.

### Why does `sendMessage(...)` reject with `WATCH_NOT_REACHABLE`?

The message channel is live and requires the watch to be reachable. On iOS a phone-to-watch message does not launch the watch app, so the watch app has to be running and in range. When delivery matters more than immediacy, `transferUserInfo(...)` queues the payload until it gets through.

### Can a Wear OS watch talk to an iPhone app?

No. An Apple Watch pairs only with an iPhone, and the Google Play services Data Layer connects only Android phones with Wear OS watches. Both are platform rules rather than plugin limitations.

### Is the Capacitor Watch plugin free?

No. It is part of [Capawesome Insiders](../../insiders/index.md), which covers the other Insiders plugins and priority support as well.

### Does this work with Angular, React, or Vue?

Yes. The plugin is framework-agnostic, so the same TypeScript runs in an Angular, React, or Vue app and in a plain JavaScript project. Only the watch target is platform-specific code.

## Try Capawesome Cloud

A watch companion only reaches users once the iPhone app ships. Capawesome Cloud builds your Capacitor app in the cloud, delivers live updates to its web layer, and publishes to the App Store.

[Try Capawesome Cloud Free](https://capawesome.io){ .md-button .md-button--primary }

## Conclusion

Start from the [`example/watchos`](https://github.com/capawesome-team/capacitor-plugins/tree/main/packages/watch/example/watchos){:target="_blank"} app and get one message round trip working on paired hardware before you design a single screen. That first round trip proves the bundle identifiers, the package reference, and the signing setup all at once, and everything after it is SwiftUI. When the Apple Watch side works, the same TypeScript drives a Wear OS build, which [How to Build a Wear OS App for Your Capacitor App](./how-to-build-a-wear-os-app-for-your-capacitor-app.md) walks through on the Android side. For a fitness companion, the [Capacitor Health plugin](../../sdks/capacitor/health.md) reads the steps, heart rate, and workouts the watch writes to Apple Health. Questions about your setup, or something to show? Join the [Capawesome Discord server](https://discord.gg/VCXxSVjefW){:target="_blank"}, and subscribe to the [Capawesome newsletter](https://capawesome.io/newsletter/){:target="_blank"} for new plugin releases and guides.
