Shipping a web app through App Store review with Capacitor
Capacitor will wrap a working web application into a native binary in an afternoon. Review is the part that takes weeks, and almost none of it is about the wrapper. It is about the handful of things Apple checks that a website never had to answer for.
A wrapped site is not automatically rejected. A thin one is
The rule that gets quoted is that an app must not be a repackaged website. The rule as
applied is narrower: the app must do something a Safari tab does not. Native session
persistence, push, camera, location, offline behaviour, a home-screen presence with real state
— any of these move it across the line. A binary whose entire content is a full-page
WKWebView pointed at a public URL, with the same navigation chrome the site has,
is the case that gets refused.
Never navigate with a link
This is the failure that produced the most confusing bug I have shipped. Inside a wrapped
app, an <a href> to another page is a full document load. The tab bar
disappears while it loads, the JavaScript context is destroyed, and if the network is slow or
briefly absent, the user gets the connection-failure screen instead of the app. It looks like a
crash and it is reported as one.
Inside the shell, every navigation must be a state change — switch the tab, render the view — and the only real page load is the one at launch. This is also what makes the app survive a lift lobby.
Draw no control that leads nowhere
A back arrow with nothing behind it is worse than no arrow, because a person taps it
expecting to return. The naive fix — always draw it, call history.back(),
and fall back to the site root when nothing happens — is a control that lies, and it
lands the user somewhere they never asked to be.
The platform will not tell you the answer. history.length counts entries from
before your app existed and never shrinks, and there is no canGoForward outside the
Navigation API, which WebKit does not ship. So keep the bookkeeping yourself: stamp each entry
with an index in history.state, remember the highest index reached in
sessionStorage, and derive back from index > 0 and forward from
index < highest. A new push truncates what was ahead, so pull the highest back
down to match, or the forward arrow becomes a lie the moment someone branches.
Recompute on pageshow, not only on popstate. Returning to a page
that was restored from the back-forward cache does not re-execute the document and does not fire
popstate, so the arrows keep whatever they were showing when the page was frozen.
That is the case that actually breaks, and it only reproduces on a device.
The four rejections that are administrative
Account deletion must be in the app. If a user can create an account inside the binary, they must be able to delete it and their data inside the binary too. Not a support email, not a web form — the same flow, in the app.
A reviewer account that works. A demo login that reaches the actual product, not a marketing screen, with a note saying exactly what to tap. Half of first-round rejections are a reviewer who could not get in.
The iPad build is reviewed as an iPad build. If the app is offered for iPad, someone will open it on one, and a login form pinned to the top-left of a 12.9-inch screen is a rejection. Either support the size class properly or declare iPhone only and mean it.
Privacy answers must match the binary. The data-collection declarations are checked against what the app actually sends. An analytics SDK you forgot is in there is a contradiction, and contradictions are the thing review is best at finding.
What review is really testing
Not novelty, and not architecture. Whether the thing behaves like an application to someone holding it for ninety seconds on an unreliable network. Every item above is a version of that same question.
Written by Liana Grigory, also written Liana Grigoryan — entrepreneur, technology founder and U.S. Army veteran in Los Angeles. More at Writing.