Originally published on Hashnode.
converting a Lovable app to native isn't a wrapper job and it isn't a rewrite of your backend. You keep Supabase, your database schema, your RLS policies, your types and most of your business logic. You rebuild the UI layer with React Native primitives and NativeWind, replace React Router with Expo Router, re-plumb Supabase auth for a mobile runtime (session storage, token refresh, OAuth redirects through deep links), swap web-only libraries for native ones, and add the pieces the stores require. This guide walks through each layer with the code we actually use.
Lovable, like Bolt and v0, generates a Vite + React + TypeScript single-page app, usually styled with Tailwind and shadcn/ui and backed by Supabase. It's a great stack for the web. It's also exactly the stack that gets rejected under App Store Guideline 4.2 when it's wrapped in a WebView and submitted as an app.
Disclosure: this is written by the team at LetsDeployIt, where native conversion of AI-built apps is our day job. Everything below is the process we follow, so you can do it yourself.
1. Why not just wrap it?
A Capacitor or WebView wrapper ships your existing bundle inside a native shell. It's fast, and for some apps it's fine. For AI-built apps it tends to fail in three places:
- Review. Guideline 4.2 rejects apps that are "a repackaged website". A WebView with browser-style scrolling, no native navigation and no native features is the textbook case. More on Guideline 4.2.
- Feel. Text inputs, the keyboard, momentum scrolling, back gestures and transitions all behave like a browser, and users notice.
- Platform features. Push notifications, secure storage, biometrics and background work are possible through plugins, but every one is a bridge you maintain.
Conversion keeps your data layer and rebuilds the presentation layer natively, which removes all three problems.
2. Audit the Lovable codebase first
Before writing any React Native, split the existing code into what ports and what doesn't. Clone the GitHub repo Lovable syncs to and sort files into four buckets:
-
Portable as-is: TypeScript types (including generated Supabase types in
src/integrations/supabase/types.ts), zod schemas, pure utility functions, date and currency formatting, and data access functions that only callsupabase. -
Portable with changes: React hooks that use
supabaseplus browser APIs (localStorage,window,document), and anything using TanStack Query (which works in React Native unchanged, but focus and online handling need wiring). -
Rewrite: every component that renders DOM elements, everything under
src/components/ui(shadcn), layout and routing. - Replace or drop: web-only libraries (Recharts, rich text editors, drag-and-drop, PDF generation), SEO and marketing pages, and Stripe Checkout for digital goods.
A quick way to size the job:
# Components that render DOM and will need rewriting
grep -rlE "<(div|span|section|button|img|input|form)[ >]" src --include=*.tsx | wc -l
# Browser APIs that need replacing
grep -rnE "localStorage|sessionStorage|window\.|document\." src --include=*.ts --include=*.tsx
# Web-only dependencies to swap
grep -E "\"(recharts|@radix-ui|react-router-dom|react-dropzone|@tiptap|react-dnd)" package.json
3. Scaffold the Expo app
Start from a fresh Expo project with TypeScript and Expo Router rather than trying to bend the Vite project:
npx create-expo-app@latest my-app-native
cd my-app-native
npx expo install nativewind react-native-reanimated react-native-safe-area-context
npx expo install @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill
npx expo install expo-web-browser expo-auth-session expo-image-picker expo-notifications expo-device
Copy the portable code (bucket 1) into a src/lib or src/shared folder. If you want both apps to keep sharing it, move it into a workspace package in a monorepo; for most AI-built apps, a copy is simpler and fine.
Configure NativeWind so your Tailwind classes keep working. With NativeWind v4, that means the NativeWind preset in tailwind.config.js, a global.css with the Tailwind directives, the Babel preset and the Metro wrapper:
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });
Copy your tailwind.config theme (colors, fonts, radius) across so the native app matches your brand.
4. The component translation map
| Web (Lovable / Vite) | React Native + Expo | Notes |
|---|---|---|
<div>, <section>, <span>
|
<View>, <Text>
|
Every string must sit inside <Text>
|
onClick |
onPress on <Pressable>
|
No hover; use active: styles for press state |
<img src> |
<Image source={image}> with { uri } or expo-image
|
Remote images need explicit width and height |
<input onChange> |
<TextInput onChangeText> |
Wrap forms in KeyboardAvoidingView
|
| Tailwind CSS | NativeWind | Default flex direction is column; no CSS grid |
| shadcn/ui, Radix | RN primitives or react-native-reusables | Radix depends on the DOM |
| React Router | Expo Router | File-based routes in app/
|
localStorage |
AsyncStorage / SecureStore | Async API; SecureStore has value size limits |
import.meta.env.VITE_* |
process.env.EXPO_PUBLIC_* |
Inlined at build time, so public by definition |
lucide-react |
lucide-react-native |
Needs react-native-svg
|
| Recharts | victory-native, react-native-gifted-charts | Recharts renders SVG via the DOM |
.map() in a scrolling div |
FlatList |
Virtualized; required for long lists |
| Stripe Checkout for digital goods | In-app purchase (StoreKit / Play Billing) | Guideline 3.1.1 |
Here's a typical card from a Lovable app and its native version:
// Web (Lovable)
<div
className="flex items-center gap-3 p-4 rounded-xl bg-white shadow hover:bg-gray-50"
onClick={() => navigate(`/projects/${p.id}`)}
>
<img src={p.avatarUrl} className="w-10 h-10 rounded-full" />
<span className="font-medium text-gray-900">{p.name}</span>
</div>
// React Native + NativeWind
import { Pressable, Image, Text } from 'react-native';
import { useRouter } from 'expo-router';
const router = useRouter();
const avatar = { uri: p.avatarUrl };
<Pressable
className="flex-row items-center gap-3 p-4 rounded-xl bg-white active:bg-gray-50"
onPress={() => router.push(`/projects/${p.id}`)}
>
<Image source={avatar} className="w-10 h-10 rounded-full" />
<Text className="font-medium text-gray-900">{p.name}</Text>
</Pressable>
Three changes do most of the work: flex-row is now explicit (React Native defaults to column), hover: becomes active:, and text moves into <Text>. Shadows deserve a check on both platforms, because iOS uses shadow properties and Android uses elevation.
5. Routing: React Router to Expo Router
Lovable apps define routes in App.tsx with React Router. Expo Router uses the file system instead:
React Router Expo Router (app/)
/ -> Home (tabs)/index.tsx
/settings -> Settings (tabs)/settings.tsx
/projects/:id -> Project projects/[id].tsx
/login -> Login (auth)/login.tsx
layout with navbar (tabs)/_layout.tsx (bottom tab bar)
The hooks map almost one to one: useNavigate() becomes useRouter() with router.push() and router.back(), useParams() becomes useLocalSearchParams(), and <Link to> becomes <Link href>.
Treat this as a design step, not just a rename. A top navigation bar on the web usually becomes a bottom tab bar with three to five tabs on mobile, and secondary pages become stack screens with a native back gesture. Getting navigation native is one of the biggest things reviewers notice.
Protect routes with a redirect in a layout instead of a wrapper component:
// app/(tabs)/_layout.tsx
import { Redirect, Tabs } from 'expo-router';
import { useSession } from '@/lib/auth';
export default function TabsLayout() {
const { session, loading } = useSession();
if (loading) return null;
if (!session) return <Redirect href="/(auth)/login" />;
return <Tabs />;
}
6. Supabase on mobile: the part most conversions get wrong
The Supabase client Lovable generates assumes a browser: it stores the session in localStorage, refreshes tokens on a timer that runs while the tab is open, and reads OAuth results from the URL. None of that holds on a phone.
The client
// src/lib/supabase.ts
import 'react-native-url-polyfill/auto';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
import { AppState } from 'react-native';
import type { Database } from './types';
export const supabase = createClient<Database>(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
flowType: 'pkce',
},
}
);
// Only refresh tokens while the app is in the foreground
AppState.addEventListener('change', (state) => {
if (state === 'active') supabase.auth.startAutoRefresh();
else supabase.auth.stopAutoRefresh();
});
Key points:
-
detectSessionInUrl: false, because there's no browser URL to read. - The AppState listener pauses and resumes token refresh with the app lifecycle, as Supabase recommends for React Native.
-
Storage choice: AsyncStorage isn't encrypted. If you want the session encrypted at rest, use
expo-secure-store, but note it has a per-value size limit that a Supabase session can exceed. The common pattern is to encrypt the session with a key kept in SecureStore and store the ciphertext in AsyncStorage. -
Keys: only the anon (publishable) key belongs in the app.
EXPO_PUBLIC_variables are inlined into the bundle, so anything there is readable by anyone who downloads the app. The service role key never leaves the server.
OAuth with deep links
On the web, Supabase redirects back to your site. On mobile, it has to redirect back into your app through a URL scheme. Set "scheme": "myapp" in app.json, add the redirect URL (for example myapp://) to Supabase's allowed redirect URLs, then:
import * as WebBrowser from 'expo-web-browser';
import { makeRedirectUri } from 'expo-auth-session';
import { supabase } from './supabase';
WebBrowser.maybeCompleteAuthSession();
const redirectTo = makeRedirectUri();
export async function signInWithGoogle() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo, skipBrowserRedirect: true },
});
if (error) throw error;
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo);
if (result.type !== 'success') return;
const code = new URL(result.url).searchParams.get('code');
if (!code) throw new Error('No auth code returned');
const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);
if (exchangeError) throw exchangeError;
}
With the PKCE flow, the app receives a short-lived code and exchanges it for a session, so tokens never travel in the redirect URL. If you offer Google login on iPhone, you also need Sign in with Apple under Guideline 4.8; on iOS, the native Apple sheet with signInWithIdToken is the smoothest way to add it.
Email links
Magic links and password reset emails point at your website by default. Either point them at a universal link or app link that opens the app, or keep that flow on the web and have the app hand off to it. Decide this early, because it touches your email templates and Supabase's redirect settings.
7. Data fetching, lists and forms
Your data access functions port unchanged, since they're just supabase.from(...) calls. What changes is how results render and how the app reacts to the network.
Lists: replace .map() inside a scrolling container with FlatList, which only renders what's on screen:
<FlatList
data={projects}
keyExtractor={(p) => p.id}
renderItem={({ item }) => <ProjectCard project={item} />}
onEndReached={fetchNextPage}
refreshing={isRefetching}
onRefresh={refetch}
/>
Pull-to-refresh comes almost free and is something users expect on mobile.
TanStack Query: it works in React Native, but it can't see browser focus and online events. Wire focusManager to AppState and onlineManager to @react-native-community/netinfo so queries refetch when the app returns to the foreground or the connection comes back.
Forms: react-hook-form and zod port well; swap inputs for TextInput with onChangeText, wrap screens in KeyboardAvoidingView, and set keyboardType, autoCapitalize, autoComplete and textContentType so the keyboard and password managers behave natively.
8. File uploads to Supabase Storage
The web version uploads a File from an <input type="file">. On mobile you get a local URI from the image picker, then upload its bytes:
import * as ImagePicker from 'expo-image-picker';
export async function pickAndUpload(userId: string) {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.8,
});
if (result.canceled) return null;
const asset = result.assets[0];
const bytes = await fetch(asset.uri).then((r) => r.arrayBuffer());
const path = `${userId}/${Date.now()}.jpg`;
const { error } = await supabase.storage
.from('uploads')
.upload(path, bytes, { contentType: asset.mimeType ?? 'image/jpeg' });
if (error) throw error;
return path;
}
Keep the userId/ prefix: it lets your Storage policies restrict each user to their own folder, and it makes account deletion straightforward later. Add a photo library usage description in your iOS config, or review will flag it under Guideline 5.1.1.
9. Native features worth adding
These turn a converted app into an app reviewers recognize as native, and users actually feel:
Push notifications with expo-notifications: request permission at a meaningful moment, get an Expo push token, store it against the user, and send from your backend.
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
export async function registerForPush(userId: string) {
if (!Device.isDevice) return;
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
await supabase.from('push_tokens').upsert({ user_id: userId, token });
}
On Android, create a notification channel; on iOS, EAS can generate and manage the APNs key for you.
Also worth adding: haptic feedback on key actions (expo-haptics), native share sheets, biometric unlock for sensitive screens (expo-local-authentication), an offline state instead of a blank screen, and safe area handling with react-native-safe-area-context so nothing hides under the notch or home indicator.
10. Store requirements to build in, not bolt on
Account deletion (Guideline 5.1.1(v) and Google Play). Do it server-side in an Edge Function, because deleting a user needs the service role key:
// supabase/functions/delete-account/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
Deno.serve(async (req) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader) return new Response('Unauthorized', { status: 401 });
// Identify the caller from their own JWT
const userClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
);
const { data: { user }, error } = await userClient.auth.getUser();
if (error || !user) return new Response('Unauthorized', { status: 401 });
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
// Storage isn't removed by ON DELETE CASCADE, so clean it up explicitly
const { data: files } = await admin.storage.from('uploads').list(user.id);
if (files?.length) {
await admin.storage.from('uploads').remove(files.map((f) => `${user.id}/${f.name}`));
}
const { error: deleteError } = await admin.auth.admin.deleteUser(user.id);
if (deleteError) return new Response(deleteError.message, { status: 500 });
return Response.json({ ok: true });
});
The app calls it with supabase.functions.invoke('delete-account'), which sends the user's JWT automatically, then signs out. Make sure your tables reference auth.users with ON DELETE CASCADE, or delete those rows in the function too. Full account deletion guide.
Also build in:
- Sign in with Apple (Guideline 4.8) if you offer Google or other social login.
- In-app purchase (Guideline 3.1.1) for digital goods; Stripe stays for physical goods and services.
- Permission purpose strings for every sensitive permission.
11. Build, update and ship with EAS
Configure build profiles in eas.json:
{
"cli": { "version": ">= 12.0.0", "appVersionSource": "remote" },
"build": {
"development": { "developmentClient": true, "distribution": "internal" },
"preview": { "distribution": "internal", "channel": "preview" },
"production": { "autoIncrement": true, "channel": "production" }
},
"submit": { "production": {} }
}
- Development builds replace Expo Go once you add native modules; they're your day-to-day testing app on a real device.
-
Over-the-air updates with
expo-updateslet you ship JavaScript fixes without a store review. Set aruntimeVersionpolicy so an update never reaches a binary it isn't compatible with, and keep OTA for fixes and small changes, not changes to what the app does. -
Production builds go to App Store Connect and Play Console with
eas submit. Test through TestFlight and Play's internal testing first, and plan for Google's 14-day closed test if your account is personal and new. Closed test guide.
12. A realistic plan and timeline
For a typical Lovable app with 10 to 20 screens and Supabase:
- Audit and scaffold: a day.
- Shared code, Supabase client and auth (including OAuth): one to two days.
- Navigation and screens: the bulk of the work, roughly a screen or two per few hours once the component patterns are settled.
- Native features and store requirements: one to two days.
- Builds, TestFlight, store listings and submission: one to two days, plus review time.
The web app keeps running throughout, on the same database, so you can launch native without a migration or a freeze.
FAQ
Can I keep using Lovable after converting? Yes for the web app. The native app lives in its own codebase, so changes made in Lovable won't flow into it automatically. Shared logic and the shared Supabase backend keep the two consistent.
Do I need to change my Supabase schema or RLS? Usually not. The same policies protect the same data. You'll add redirect URLs for mobile OAuth and possibly a table for push tokens.
Can I convert to Flutter instead? You can, but React Native lets you reuse TypeScript types, zod schemas, data functions and much of your component logic, and keeps your team in one language.
Will the converted app pass Guideline 4.2? A genuinely native app with native navigation and features is far less likely to be rejected than a wrapped web app, but review always looks at the whole app, including whether it offers enough value on its own.
Conclusion
Native conversion is mostly a UI rewrite on top of a backend you already have. The details that decide whether it works are the mobile-specific ones: session storage and token refresh, OAuth through deep links, virtualized lists, uploads from local URIs, and store requirements built into the server. Get those right and you have an app that feels native and passes review.
If you'd rather hand the conversion off, LetsDeployIt converts Lovable, Bolt, Base44 and v0 apps to React Native and Expo and takes them through both stores. See plans.
Questions about a specific part of your conversion? Ask in the comments, and we'll answer with code.
Top comments (0)