You built something in Lovable over a weekend. It works. Now you want it on a phone, and you've discovered the thing the marketing pages skip: Lovable builds React web apps, not React Native ones.
The usual advice is "wrap it in Capacitor." That advice has a failure mode I'll get to. First, the actual work, because if you're going to port this thing you should know what porting means.
- Lovable emits React DOM:
div,span,input, Tailwind classes, shadcn/ui, react-router - None of those exist in React Native. Not "work differently." Do not exist.
- Your Supabase layer ports over essentially unchanged, which is most of your backend work saved
- Wrapping in a webview risks an App Store rejection under Guideline 4.2
- Budget the port at the UI layer and nowhere else
The Translation Table
This is the part you actually need. Every Lovable-generated component maps to something, or to nothing.
| React DOM (Lovable) | React Native |
|---|---|
<div> |
<View> |
<span>, <p>, <h1>
|
<Text> |
<input> |
<TextInput> |
<button onClick> |
<Pressable onPress> |
<img src> |
<Image source> or expo-image
|
<a href> |
<Link> from expo-router |
<ul> / .map()
|
<FlatList> |
onChange={e => e.target.value} |
onChangeText={text => ...} |
localStorage |
AsyncStorage or MMKV |
react-router-dom |
expo-router |
| Tailwind classes | NativeWind, or StyleSheet.create
|
| shadcn/ui | no direct port. gluestack-ui or Tamagui |
Two entries there are bigger than they look.
react-router-dom to expo-router is not a swap, it's a restructure. Web routing is a component tree. Expo routing is file-based with native stack and tab semantics underneath. Do this first, before you touch individual screens, or you'll do the screens twice.
shadcn/ui has no port. It's built on Radix, which is built on DOM primitives. Every shadcn component in your Lovable project is a rewrite. This is usually the single largest line item in the whole migration.
What This Looks Like in Practice
Here's a login form the way Lovable writes it:
// Lovable output
export function LoginForm() {
const [email, setEmail] = useState('');
const navigate = useNavigate();
return (
<div className="flex flex-col gap-4 p-6">
<h1 className="text-2xl font-bold">Sign in</h1>
<input
className="border rounded px-3 py-2"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<button
className="bg-blue-600 text-white rounded py-2 hover:bg-blue-700"
onClick={() => navigate('/home')}
>
Continue
</button>
</div>
);
}
And the same thing in React Native:
// React Native
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
export function LoginForm() {
const [email, setEmail] = useState('');
const router = useRouter();
return (
<View style={styles.container}>
<Text style={styles.heading}>Sign in</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
/>
<Pressable style={styles.button} onPress={() => router.push('/home')}>
<Text style={styles.buttonText}>Continue</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flexDirection: 'column', gap: 16, padding: 24 },
heading: { fontSize: 24, fontWeight: 'bold' },
input: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 12, paddingVertical: 8 },
button: { backgroundColor: '#2563eb', borderRadius: 6, paddingVertical: 8 },
buttonText: { color: '#fff', textAlign: 'center' },
});
Four things changed that aren't obvious from the table:
-
The button label needed its own
<Text>. You cannot put a bare string inside aPressable. Every piece of text on screen lives in aTextnode, always. -
hover:bg-blue-700vanished. There's no cursor. If you want press feedback you write it, usually viaPressable's style callback. -
autoCapitalizeandkeyboardTypeappeared. Mobile keyboards are configurable and the defaults are wrong for email. Web never made you think about this. -
textAlign: 'center'moved onto the text, not the container. Style inheritance doesn't cascade in React Native. Text styling has to live on theTextnode.
That fourth one accounts for a genuinely stupid share of "why does this look wrong" debugging.
Things That Silently Do Nothing
These compile, run, and have no effect. No warning, no error.
// all of this is ignored
<View style={{
position: 'fixed', // only absolute and relative exist
display: 'grid', // flexbox only
boxShadow: '0 2px 4px', // use shadowColor/shadowOffset, or elevation on Android
cursor: 'pointer', // no cursor
overflow: 'scroll', // you need ScrollView, this won't do it
}} />
That last one bites hard. On the web, content longer than the viewport scrolls by default. In React Native, content that overflows a View is just clipped and gone. If a Lovable screen had a long form, it scrolled for free, and after the port it won't. Wrap it in ScrollView and remember KeyboardAvoidingView, because the on-screen keyboard covers about half the display and users can't see the field they're typing into.
The Good News: Your Backend Ports Clean
If your Lovable app uses Supabase, and it probably does, that entire layer moves over almost untouched. Same supabase-js, same queries, same auth calls, same RLS policies.
// works identically in both
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('user_id', user.id);
You do need to configure storage for native:
import AsyncStorage from '@react-native-async-storage/async-storage';
export const supabase = createClient(url, anonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false, // required on native
},
});
detectSessionInUrl: false stops the client from trying to parse a browser URL that doesn't exist. Miss it and you'll chase a null session for an hour.
So the real scope of the port is: the entire UI layer, all of routing, and none of your data model. That's better than it sounds on day one and worse than it sounds on day three.
Why Wrapping It Is a Trap
The shortcut everyone reaches for is Capacitor or a webview shell. It genuinely works, in the sense that you get a binary.
The problem is App Store Review Guideline 4.2, minimum functionality. Apple rejects apps that are essentially a repackaged website with no native capability. There's no bright line, which is the worst kind of rule to build a launch schedule around. Apps with real offline behavior, push notifications, and device integration get through. A wrapped CRUD dashboard frequently doesn't, and you find out after you've built everything else.
If the app is a genuine web product and you want distribution, a PWA is honest and cheap. If you want an App Store listing that survives review, you want native components underneath.
If You'd Rather Not Do the Port by Hand
The port above is mechanical, which is exactly the kind of work worth handing off. RapidNative converts Lovable projects into React Native and Expo apps and takes them through submission: signing, certificates, screenshots, metadata, privacy labels, data safety forms. You get the source, so this isn't a black box you're stuck with. If either store rejects, they fix and resubmit at no extra cost until it's live, which is the part that actually de-risks the 4.2 problem above. Typical turnaround is one to two weeks.
Whether you hand it off or grind through it manually, the translation table above is what's actually happening underneath. Worth understanding either way, because you'll be debugging it.
The Short Version
Lovable is a good tool pointed at the web. React Native is a different rendering target, not a different flavor of the same one. The port is real work concentrated almost entirely in the UI layer, your Supabase code comes along for free, and the wrapper shortcut trades a week of porting for an unbounded risk at review time.
What tripped you up most on a web-to-native port? I'm collecting the silent-failure list and overflow: scroll is only number two.
Top comments (0)