Court Canvas: from React prototype to native Swift

How a tactical tennis whiteboard went from Claude artifacts to a Next.js PWA to an App Store rejection to a SwiftUI rewrite.

Court Canvas is a free tactical whiteboard for tennis and pickleball coaches. You drop player markers on a court, draw arrows and shapes, add text, save plays, and export them as GIFs. The thing that makes it different from a generic drawing app is the ATP-backed shot probability heatmaps — they show where pros actually hit from different positions on the court. No login, works on your phone, meant to be used standing on the court between sets.

Starting in artifacts

The first version was a React component I built iterating with Claude in artifacts. Once it outgrew that, I pulled it into its own project — a Next.js app deployed on Vercel at courtcanvasapp.com. Tailwind for the iOS-inspired UI, PostHog for analytics, full i18n across English, Spanish, and French. I later added proper PWA support: manifest, service worker with offline fallback, beforeinstallprompt handling for Chrome, and the manual “Add to Home Screen” guide for iOS Safari. 252 tests, GitHub Actions CI/CD, WCAG 2.1 AA accessible. The web version is solid.

The Capacitor detour

When it was time to ship an iOS app, I took the path of least resistance: wrap the existing React app with Capacitor so I could keep one codebase. Got it into Xcode, got it building, submitted it.

Apple bounced it. Classic wrapped-PWA territory — Guideline 4.2 risk. Worse, the in-app purchase integration through the Capacitor StoreKit plugin was broken. Users would tap “Unlock Pro” and get “Store unavailable — try again later.” The JS-to-native bridge for IAP was fighting me the whole way.

Rewriting in SwiftUI

Rather than keep debugging a bridge I didn’t trust, I started a separate Xcode project in SwiftUI. The rewrite was partly about killing the “it’s just a webview” rejection risk and partly about getting real StoreKit 2 integration for the $4.99 Pro unlock.

What got better immediately:

  • Real StoreKit 2 with the com.apple.developer.in-app-payments entitlement wired properly. No JS bridge, no Capacitor plugin, no mystery failures.
  • Native touch drawing. Gestures that feel like gestures instead of a webview trying its best.
  • Cleaner review path. It’s actually native code now, not a wrapper Apple has to squint at.
  • iPhone-only via TARGETED_DEVICE_FAMILY = 1 instead of universal, which simplified the whole build.

The IAP debugging saga

The in-app purchase issue that ate days of my time turned out to be oddly mundane. Two problems: a missing .entitlements file in the build config, and a literal $ character I’d typed into the StoreKit configuration price field. Once both were fixed, sandbox purchases went through on the first try. Days of debugging, seconds of fixing.

I used Claude Code heavily through the rewrite — autonomous coding through the .pbxproj edits, entitlements config, build settings, all of it. My workflow was basically “describe the rejection, let it dig.” It worked.

Where it stands

The web app is live and pulling small traffic from friends and posts on r/10s. Build 1.1.0(2) of the native app is sitting in App Store review. I’m holding off on any real marketing push until the native version is approved so the launch lands in both places at once.

The lesson

The Capacitor shortcut saved weeks on v1 but cost them back on the IAP and rejection loop. For a touch-heavy drawing app where Pro conversion matters, native was always going to be where it ended up. Doing Capacitor first just meant learning that the hard way.

← all notes