TL;DR
- We categorized twelve months of Applighter support tickets. The framework isn't the problem; the perimeter is.
- Top 3: EAS Build failing on first run, Supabase RLS returning
[], env varsundefinedin production builds. - Run
bunx expo doctorbefore asking for help. It usually names the broken file. - Only
EXPO_PUBLIC_vars reach the app bundle, and your.envis probably gitignored from EAS. - Use
expo/fetchfor AI streaming. The default RNfetchwon't stream. - Put your cache reset in a script. You'll type it wrong at 2am otherwise.
People file tickets when they're bleeding, not when they're reflecting. So a year of support tickets paints a very different picture than community surveys. Here's the top ten, ordered by volume, with the fix for each.
| # | Problem | Layer |
|---|---|---|
| 1 | EAS Build fails on first run | build |
| 2 | Supabase RLS returns empty arrays after auth | backend |
| 3 | Env vars don't reach the native binary | build |
| 4 | Metro cache poisoning after a dependency bump | build |
| 5 | Missing iOS Info.plist permission strings | ios |
| 6 | Android package name conflicts on rename | android |
| 7 | Android push silently not delivering | native |
| 8 | AI streaming stalls / drops tokens | ai |
| 9 | Stripe webhook not reaching a Supabase Edge Function | payment |
| 10 | Voice transcription latency on mid-range Android | ai |
1. EAS Build fails on first run
Clone, prebuild, build, and it dies at "Install pods" or in Fastlane signing. Three usual causes:
- Template SDK and local CLI disagree (and nobody ran
expo doctor) - A device profile used before provisioning exists
- No
EXPO_TOKENin CI
Our templates now ship a preflight that blocks the build if doctor is red:
#!/usr/bin/env bash
# scripts/preflight.sh
set -e
bunx expo doctor || { echo "❌ Fix expo doctor issues before building"; exit 1; }
If you're building from CI, make sure the token is set:
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
2. Supabase RLS returns [] after auth
"I'm signed in, I see my user ID, but select * returns nothing." Supabase isn't broken; RLS is working.
A typical owner-only policy:
alter table projects enable row level security;
create policy "owners can read their projects"
on projects for select
using (auth.uid() = owner_id);
Checklist:
- Log the raw
Authorizationheader. The token is oftenundefined. -
auth.uid()vsauth.jwt() ->> 'sub'behave differently with anon requests mixed in. - Never ship the
service_rolekey in a client bundle. - Access tokens expire after an hour by default. Make sure the session refreshes:
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
},
);
The full auth setup (email, OAuth, Sign in with Apple) is in our Supabase auth in React Native guide.
Mental model: policies are WHERE clauses that always run.
3. Env vars are undefined in production
Works in Expo Go, works in the simulator, undefined in the standalone build. Two reasons: .env is usually gitignored so EAS never sees it, and only EXPO_PUBLIC_ vars are inlined into the bundle.
- API_URL=https://api.example.com
+ EXPO_PUBLIC_API_URL=https://api.example.com
Then declare the values for EAS, either as EAS environment variables or per profile:
{
"build": {
"production": {
"env": { "EXPO_PUBLIC_API_URL": "https://api.example.com" }
}
}
}
Reminder: EXPO_PUBLIC_ means public. No secrets.
4. Metro cache poisoning
Dependency bump, inscrutable crash, nothing works until someone says "reset the cache." Script it:
{
"scripts": {
"reset": "watchman watch-del-all; rm -rf node_modules ios/Pods .expo && bun install && bunx expo prebuild --clean"
}
}
Lighter first attempt: bunx expo start --clear.
5. Missing Info.plist permission strings
Camera, mic, photos, or location without a purpose string means a failed upload or a review rejection. Say why, specifically:
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "This app needs camera access to scan documents.",
"NSMicrophoneUsageDescription": "This app records voice notes."
}
}
}
}
"This app uses the camera" isn't enough.
6. Android package rename breaks the build
The package name lives in app.json, build.gradle, AndroidManifest.xml, the Java/Kotlin source path, google-services.json, and OAuth redirect URIs. Miss one and you get a ClassNotFoundException on launch. On managed Expo, let the tooling regenerate:
rm -rf android
# set expo.android.package in app.json
bunx expo prebuild --clean
7. Android push says "ok" but nothing shows
Usual suspects: FCM credentials not configured in EAS, OEM battery optimization, no channel, or no Android 13+ permission. Cover the last two in code:
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function setupAndroidPush() {
if (Platform.OS !== 'android') return;
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.HIGH,
});
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') console.warn('Push permission denied');
}
8. AI streaming stalls or drops tokens
React Native's default fetch doesn't do ReadableStream. Use expo/fetch:
import { fetch } from 'expo/fetch';
const res = await fetch(url, { method: 'POST', body, headers });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
onChunk(decoder.decode(value, { stream: true }));
}
9. Stripe webhook never reaches Supabase
Three causes: signing secret mismatch, JWT verification on (Stripe doesn't send a Supabase JWT), or pointing at a preview URL.
# supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false
Then fire a test event before going live:
stripe trigger checkout.session.completed
10. Voice transcription feels slow on budget Android
Mostly hardware and network, not a bug. Chunk audio at shorter intervals (a few seconds, not ten), show a typing indicator, and fall back to a smaller model tier on older devices:
const model = isHighEndDevice ? config.fastModel : config.smallModel;
The takeaway
React Native the framework has stabilized. The pain is in the seams: build tooling, permissions, backends, push, AI providers, device quirks. If you're prioritizing what to learn this week:
-
eas.jsonand build profiles - Your backend's auth model (RLS if Supabase)
- Env var propagation
- A scripted cache reset
- Apple's common rejection reasons
Which of these ten has cost you the most hours? Or is your worst one not on the list? Drop it in the comments. 👇
Top comments (0)