Capacitor · 4 min read

Capacitor app API calls fail on capacitor://localhost. The website works fine.

Every API call in the app fails. Every curl against production works. That gap is the clue. Your app is calling itself.

What you see

Your website works. You wrap it in Capacitor and every API call in the app fails. Login, data, uploads, all of it.

If the app is in App Review, it gets rejected again and again for "cannot sign in". Meanwhile every curl you run against the production login works perfectly.

Why it happens

When a Capacitor app bundles its web files, those pages are not served from your domain anymore. They are served from a local origin inside the phone, such as capacitor://localhost or https://localhost.

So a relative path like /api/login or /.netlify/functions/x resolves to the phone itself. There is nothing there. The call fails.

That is why curling the backend proves nothing. The backend is fine. The app never reaches it.

The fix, step by step

There are two good fixes. Pick one on purpose. If the app's content is already a live website, Option A is usually simpler. If you need the app to ship fixed, reviewed files, use Option B. Either way, write down which mode each app uses so nobody expects a web deploy to fix a bundled app.

Option A: load the live site in the native shell

Point the app at your production site with server.url in capacitor.config.json. Relative paths then resolve to your real domain:

{
  "server": {
    "url": "https://yourdomain.com",
    "allowNavigation": ["yourdomain.com"]
  }
}

Keep allowNavigation to your own host only. Hosts listed there stay inside the webview with no back button, so phone links, WhatsApp, maps and other sites should open in the OS instead.

A bonus: web deploys reach installed apps instantly, with no new binary. The cost: Apple can reject thin wrappers under Guideline 4.2, so add real native value such as push, camera and splash.

Option B: keep bundled files, use an absolute API base

Step 1. Add a small fetch shim that rewrites root relative API paths to your production origin, only inside the native app:

(function () {
  var C = window.Capacitor;
  if (!(C && C.isNativePlatform && C.isNativePlatform())) return;
  var API = 'https://yourdomain.com';
  var origFetch = window.fetch;
  window.fetch = function (input, init) {
    if (typeof input === 'string' && input.charAt(0) === '/') {
      input = API + input;
    }
    return origFetch.call(this, input, init);
  };
})();

Load it before any other script makes a request.

Step 2. Allow the app's origin in your CORS rules. Your backend now gets requests from the app origin, so it must answer them:

const ALLOWED = [
  'https://yourdomain.com',
  'capacitor://localhost',
  'https://localhost'
];
const origin = event.headers.origin;
const headers = ALLOWED.includes(origin)
  ? { 'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Headers': 'Content-Type, Authorization' }
  : {};

Step 3. Remember that with bundled files, every JavaScript fix needs a new binary. A fix that is live on the website will not reach people using the installed app until you ship one.

Test it the right way

Test the bundled app from the app's own origin. Serve the bundled web folder on http://localhost and drive the real login form with a browser automation tool such as puppeteer. If login works there, it will work in the app. Curling the backend does not count.

How to prevent it

Related traps

Field Notes

This fix is one of 228 in Field Notes: What Broke and How We Fixed It.

Every entry follows the same pattern: the symptom you see, the real cause, the fix that worked, and the rule that stops it coming back. App Store, Google Play, Capacitor, Netlify, Supabase, Stripe, n8n and more.

Get Field Notes

Building a lot? The Builder Bundle has Field Notes plus the launch playbook, the prompt book and the Claude Code skills kit. See the bundle.

Related fixes

Keep reading.