MediumYour Lovable app works in the browser. In a Capacitor wrap, the sign-up email is where it breaks. | by Sunnatulla E. | Nov, 2026 | Medium
Sitemap
Sunnatulla E.

Sunnatulla E.

Full-stack web developer (React, Next.js, Node, PHP). I write about shipping small apps safely: Supabase, Stripe, LLM agents.

Your Lovable app works in the browser. In a Capacitor wrap, the sign-up email is where it breaks.

6 min read6 days ago

--

AI note: this post was written with an AI agent, from a project of mine. Every test result quoted here was re-run before posting.

Wrapping a React + Supabase web app in Capacitor looks like a weekend job. You add Capacitor, point it at the dist folder, run npx cap add android, and the app opens on a phone. Lists load, buttons work, sign-in with a password works.

Then someone signs up, gets the confirmation email, taps the link, and lands in Chrome. The app never hears about it. They are “confirmed” in a browser tab that has no session, and when they go back to the app they are still signed out.

Get Sunnatulla E.’s stories in your inbox

Join Medium for free to get updates from this writer.

I hit all of this while building lovable-to-stores, a personal project that takes a Lovable-style React + Supabase app and wraps it for Android and iOS. Here are the six things I had to fix, in the order they bite.

1. The confirmation link has to point at the app

Supabase sends the user to whatever emailRedirectTo you pass to signUp. In the browser that's your site. In the app it has to be a URL the phone hands to your app, which for a start means a custom scheme:

export function authCallbackUrl(isNative: boolean, webOrigin: string): string {
return isNative
? `${APP_URL_SCHEME}://${AUTH_CALLBACK_PATH.slice(1)}`
: `${webOrigin}${AUTH_CALLBACK_PATH}`;
}

So the app sends io.github.atokim4.readlist://auth/callback and the website sends https://<site>/auth/callback. Two things people forget:

  • Both URLs go into Supabase under Authentication, URL Configuration, Redirect URLs. If the app’s URL isn’t on that list, Supabase quietly falls back to the Site URL and you’re back in the browser.
  • The scheme has to be registered on the native side. On Android that’s an intent filter with VIEW, DEFAULT and BROWSABLE on the main activity. On iOS it's CFBundleURLTypes in Info.plist. I have a test that reads both files and checks the scheme matches the Capacitor app id, because these drift apart the first time someone renames the app.

2. Use PKCE, and turn off URL detection

With the older implicit flow, the tokens come back in the URL fragment. That’s not great in a link that passes through a mail app and the operating system. With PKCE the link carries a one-time ?code=, and only the device that started the sign-up holds the secret (the code verifier) needed to exchange it.

createClient(env.url, env.publishableKey, {
auth: {
flowType: "pkce",
detectSessionInUrl: false,
persistSession: true,
autoRefreshToken: true,
storage: options.storage,
},
});

detectSessionInUrl: false matters in the wrap. The WebView's own URL is something like https://localhost/. The deep link arrives through a Capacitor event, not through the address bar, so automatic detection never sees it. I do the exchange myself on /auth/callback for both the web build and the app, so there's one code path.

3. Handle both the cold start and the warm app

A link can reach the app in two ways. If the app is already running, Capacitor fires appUrlOpen. If the link starts the app, the event can fire before your listener exists, so you also have to ask for the launch URL:

const handles = await Promise.all([
App.addListener("appUrlOpen", ({ url }) => open(url, false)),
App.addListener("backButton", ({ canGoBack }) => {
if (canGoBack) goBack();
else void App.exitApp();
}),
]);
const launch = await App.getLaunchUrl();
if (launch?.url) open(launch.url, true);

The cold start uses replace: true, so the back button doesn't take the user to an empty start screen first. And while you're in there: without a backButton listener, the Android back button closes the app from any screen. Users notice that in the first minute.

4. Exchange the code once

A PKCE code works one time. In development React’s StrictMode runs effects twice, and the cold-start path and the event path can both deliver the same link. The second exchange fails, and if you show that error, the user sees “That link didn’t work” right after they were signed in. A ref keyed on the code fixes it:

const exchanged = useRef<string | null>(null);
useEffect(() => {
if (!code || error || exchanged.current === code) return;
exchanged.current = code;
client.auth
.exchangeCodeForSession(code, { flowId })
.then(({ error: exchangeError }) => {
if (exchangeError) setError(exchangeErrorMessage(exchangeError));
else void navigate("/items", { replace: true });
})
.catch(() => setError("Could not reach the server. Check your connection and open the link again."));
}, [client, code, flowId, error, navigate]);

5. Explain the “other device” error in plain words

This one confuses real users. Someone signs up on their laptop, then taps the email on their phone. The phone has no code verifier, so supabase-js returns pkce_code_verifier_not_found. The raw message means nothing to them, and their email is actually confirmed at that point. So I map the codes:

if (error.code === "pkce_code_verifier_not_found" || error.code === "flow_state_not_found") {
return (
"This link has to be opened on the device, and in the app or browser, where the account was " +
"created. Your email is probably confirmed already, so try signing in."
);
}
if (error.code === "flow_state_expired") return "This link has expired. Sign in to get a new one.";

Expired links are a separate case. There Supabase puts error_description in the query string or, for some errors, in the fragment, so the callback page checks both before it even tries an exchange.

6. Don’t keep the session in WebView localStorage

supabase-js stores the session in localStorage by default. Inside a WebView that storage belongs to the browser engine, and iOS may clear it when the device is low on space. The user is then signed out for no reason they can see. Capacitor Preferences keeps the values in SharedPreferences on Android and UserDefaults on iOS, and it fits the storage interface supabase-js expects:

export const preferencesStorage: SupportedStorage = {
async getItem(key) {
const { value } = await Preferences.get({ key });
return value;
},
async setItem(key, value) {
await Preferences.set({ key, value });
},
async removeItem(key) {
await Preferences.remove({ key });
},
};

The PKCE verifier lives in that same storage. That has a nice side effect: if the user signs up, the OS kills the app, and they tap the email an hour later, the verifier is still there and the link still works. It’s also why point 5 says “on the device where the account was created”.

One more: check every deep link before routing it

Once the app accepts links, anyone can send one. deepLinkToPath only accepts our own scheme (and our own https origin when App Links are set up), and the resulting path goes through the same check as the ?next= redirect after sign-in. These are some of the cases from the test file:

routes io.github.atokim4.readlist://auth/callback?sb_flow_id=abc&code=x-1 to /auth/callback?sb_flow_id=abc&code=x-1
routes IO.GITHUB.ATOKIM4.READLIST://items to /items
ignores https://readlist.example.evil.example/items
ignores io.github.atokim4.readlist:////evil.example
ignores io.github.atokim4.readlist:/\evil.example
ignores other.app://items

The ////evil.example case is the one I'd have missed. Without the check it becomes the path //evil.example, which a router or window.location treats as another site.

What I checked, and what I didn’t

The deep-link, native-shell and mobile-app tests (38 of them, including “signs in from the confirmation link that opens the app” and “explains a link opened on another device and doesn’t retry it”) pass. They run in jsdom with the Capacitor plugins mocked, so they test my code and the wiring, not the phone. The Android build produces a debug APK and aapt shows the scheme's intent filter. I have not run it on a real device or emulator, I haven't built iOS (no Mac), and I haven't used it against a real Supabase project or submitted it to a store. The README says the same. The app also has no in-app account deletion yet, which Apple and Google both require if you allow sign-up, so that's the next blocker before a store submission.

If you’ve wrapped a Supabase app and the sign-up flow broke in a way that isn’t on this list, I’d like to hear it.

--

--

Sunnatulla E.
Sunnatulla E.

Full-stack web developer (React, Next.js, Node, PHP). I write about shipping small apps safely: Supabase, Stripe, LLM agents.