You built a PWA. It’s fast, it’s deployed, you push to it a dozen times a day. Then someone asks for it in the App Store, or wants haptics, or Face ID, or a push notification that shows up when the phone is locked. Now you’re staring at a React Native rewrite — a second codebase, a second state layer, a second set of bugs — for capabilities a WebView is one bridge call away from having.
That gap is what appwrap closes. It takes your already-built PWA, bundles it offline into a native shell (NativeScript underneath, a real WKWebView on iOS), and gives you a typed, isomorphic SDK to call native capabilities from the exact same web code you already ship. No rewrite. No second app to maintain in parallel.
This is a crowded idea — here’s where it sits
Wrapping web content in a native shell isn’t new, and pretending otherwise would be dishonest. Capacitor is “a cross-platform native runtime for building Web Native apps” and is the mature default in this space. On Android, Google’s own Trusted Web Activity ships your PWA to the Play Store “using a protocol based on Custom Tabs” — no wrapper code at all. So why another one?
Two reasons. First, the whole flow is bun-first and agent-drivable — appwrap ships an AGENTS.md so a coding agent can wrap your repo end to end. Second, and this is the part I actually care about, appwrap is built to never lie to you about what the resulting app can do. More on that below.
There’s also the wall everyone hits eventually: Apple. If you’re wrapping a PWA to get into the App Store, you are on a collision course with Guideline 4.2 — Minimum Functionality:
Your app should include features, content, and UI that elevate it beyond a repackaged website. If your app is not particularly useful, unique, or “app-like,” it doesn’t belong on the App Store.
A shell that only loads a URL gets rejected. A shell that uses native haptics, biometrics, secure storage, in-app purchases, and push has a real answer. The native bridge isn’t a nice-to-have here — it’s the thing that gets you through review.
The one-command version
If you don’t want to read anything, you don’t have to. From inside your PWA repo, hand this to Claude Code or Cursor:
Wrap this PWA as a native iOS app with appwrap, following
https://github.com/Livshitz/appwrap/blob/main/AGENTS.md
It adds the deps, writes appwrap.config.ts, scaffolds the native project, and builds to your device — surfacing missing prerequisites (Bun, NativeScript CLI, Xcode/CocoaPods) as it hits them. That’s not a gimmick; most of wrapping a PWA is mechanical, and an agent following a spec file does it faster than a human copying README snippets.
The manual version, so you know what’s happening
bun add -d @livx.cc/appwrap @livx.cc/native-kit
cat > appwrap.config.ts <<'EOF'
import { defineConfig } from '@livx.cc/appwrap/config';
export default defineConfig({ id: 'com.you.app', name: 'My App', version: '1.0.0', pwaDist: 'dist' });
EOF
bun run build # your existing build → dist/
bunx @livx.cc/appwrap init # generates native/ from your config
bunx @livx.cc/appwrap dev ios --sim
native/ is generated, not source you edit — the same “continuous native generation” model as Expo’s prebuild. Gitignore it, regenerate whenever config or the framework changes. Day-to-day iteration is appwrap sync, which re-copies your rebuilt bundle without touching the shell.
Two things make this a real app and not a glorified bookmark. It’s offline by default: with loader: 'app' your built PWA is bundled into the binary and served from a custom app:// origin — no network round trip on launch. (There’s also loader: 'server' for wrapping a live URL you don’t build locally, with remote-update detection layered on.) And service workers get neutralized on purpose: a cache-first SW inside a native shell serves a stale bundle and fights the shell’s own offline handling, so appwrap no-ops navigator.serviceWorker.register at document-start — while leaving Worker/SharedWorker fully intact and keeping feature detection truthful, so well-written PWAs degrade cleanly.
Calling native from web code you already wrote
import { kit } from '@livx.cc/native-kit';
await kit.ready();
if (kit.haptics.capability === 'native') await kit.haptics.impact('medium');
await kit.biometrics.authenticate('Prove it');
await kit.billing.purchase('pro_monthly'); // StoreKit/Play natively, Stripe on web — same call
The thing that keeps this from being a leaky abstraction is the capability flag: every domain reports 'native', 'web', or 'none', so you branch instead of guessing. Something with a clean web equivalent (geolocation, clipboard, share) degrades to the Web API. Something that can’t be honestly faked — Keychain-backed secure storage, biometrics — throws a typed KitError('UNSUPPORTED') instead of pretending. Billing is the deliberate exception: a purchase can’t no-op, so it gets first-class web support via a pluggable provider (Stripe/Paddle) instead of a dead end.
Twenty-nine capability domains ship on iOS today — haptics, biometrics, secure storage, camera, barcode scanner, TTS/STT speech, Sign in with Apple, HealthKit steps, calendar, contacts, in-app purchases — with Android at parity for most. modules in the config is an opt-in allow-list: declare what you use and the CLI strips the rest, so an app that doesn’t touch HealthKit doesn’t ship its entitlement or request its permission.
Where this fits and where it doesn’t
Be honest with yourself about what you’re buying. If your app is fundamentally a document — content, forms, a dashboard, checkout — wrapping it is close to free: one codebase, one deploy pipeline, plus the App Store listing and the handful of native calls (push, biometrics, haptics) that actually matter. That’s most SaaS and most internal tools.
If your app lives or dies on 60fps custom gesture handling, a heavy native rendering pipeline, or OS integration beyond what a capability bridge exposes, a WebView is the wrong foundation no matter how good the bridge is — build native. appwrap doesn’t pretend otherwise; the whole design, honest UNSUPPORTED errors instead of silent fake fallbacks, is built around not lying about which app you actually have. Remote push (APNs/FCM) and OTA updates are still on the roadmap, not shipped — I’d rather say that than let you find out at submission.
Repo: github.com/Livshitz/appwrap · npm: @livx.cc/appwrap
One thing I keep chewing on: as the capability bridge gets wide enough to pass Guideline 4.2 comfortably, the line between “wrapped PWA” and “native app” stops meaning much. If a WebView shell can do haptics, Keychain, biometrics, and IAP, what’s actually left that justifies a separate native codebase for a typical product app? Where do you draw that line?
References
- Apple — App Store Review Guidelines, 4.2 Minimum Functionality: developer.apple.com/app-store/review/guidelines
- Capacitor — cross-platform native runtime for web apps: capacitorjs.com
- Google — Trusted Web Activity (PWA → Play Store): developer.chrome.com/docs/android/trusted-web-activity
- appwrap — repo & agent spec: github.com/Livshitz/appwrap ·
AGENTS.md - npm —
@livx.cc/appwrap·@livx.cc/native-kit</content> </invoke>