MS

0
Skip to main content

React Native Expo Production Guide: Everything I Learned Shipping 5+ Apps

March 10, 2025 (1y ago)

Over the last 4.5 years, I have shipped more than five React Native applications to both the App Store and Google Play. These are not side projects that sit in a GitHub repo collecting dust. They are production apps used by real people — drivers tracking shipments, parents communicating with schools, retail customers hunting for deals, and caregivers managing elder care schedules.

I am Manjodh Singh Saran, a Senior Software Engineer and Mobile Lead at Truxo.ai based in Ludhiana, India. React Native has been the core of my career, and in this guide I am going to lay out everything that actually matters when you are building a React Native Expo app for production. Not theory. Not "getting started" tutorials. The real decisions, the real tradeoffs, and the real patterns that survive contact with users.

If you are planning to build with React Native and want to understand the full picture — from project initialization to App Store review — this is the guide I wish I had when I started.

Why Expo Is the Right Default in 2025

When I started with React Native in 2021, the "bare workflow vs Expo" debate was still raging. I have built apps in both. My current position is clear: start with Expo unless you have a proven, specific reason not to.

Expo has matured enormously. EAS Build and EAS Submit handle the entire build and deployment pipeline. Expo Router gives you file-based routing that mirrors Next.js conventions (which is great if you are a full stack developer who works across web and mobile). The Expo SDK covers camera, notifications, location, haptics, secure storage, and dozens more APIs without ever needing to eject.

The apps I have shipped recently — Truxo Tracker for trucking logistics, Super School for Florida-based school management, and Simple Life for encrypted personal data management — are all built on Expo. Each one has custom native requirements (real-time GPS tracking, push notifications, biometric auth, hardware-backed encryption), and Expo handled all of them.

The only time I would reach for the bare workflow is when you need a native module that has no Expo equivalent and you cannot write a config plugin for it. In 4.5 years, that has happened to me exactly once.

Project Setup That Scales

Here is the project structure I use on every production app. It has survived projects with 100+ screens:

src/
├── app/                  # Expo Router file-based routes
│   ├── (auth)/           # Auth group layout
│   ├── (tabs)/           # Tab navigation group
│   └── _layout.tsx       # Root layout
├── components/
│   ├── ui/               # Generic reusable components
│   └── features/         # Feature-specific components
├── hooks/                # Custom hooks
├── services/             # API layer (RTK Query or React Query)
├── store/                # Redux Toolkit slices
├── utils/                # Pure utility functions
├── constants/            # App-wide constants, config
└── types/                # Shared TypeScript types

A few principles behind this structure:

File-based routing with Expo Router. I switched to Expo Router from React Navigation about two years ago and never looked back. The mental model of "file = route" reduces an entire category of bugs. Nested layouts with _layout.tsx files handle tab bars, auth guards, and navigation stacks declaratively.

Separate ui/ from features/. Generic components like Button, Input, Card live in ui/. Components that know about your domain — ShipmentCard, StudentListItem — live in features/. This separation makes it easy to reuse ui/ across projects.

TypeScript everywhere. No exceptions. I have written about this extensively in my post on TypeScript best practices. The upfront cost is minimal and the payoff in a growing codebase is massive.

Navigation Architecture for Complex Apps

Most tutorials show you a simple tab navigator with three screens. Real apps are not like that. Truxo Tracker has authenticated and unauthenticated flows, deep linking from push notifications, and modals that can be triggered from anywhere.

Here is how I structure navigation for a production app with Expo Router:

// app/_layout.tsx — Root layout
import { Stack } from "expo-router";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
 
export default function RootLayout() {
  return (
    <AuthProvider>
      <RootNavigator />
    </AuthProvider>
  );
}
 
function RootNavigator() {
  const { isAuthenticated, isLoading } = useAuth();
 
  if (isLoading) return <SplashScreen />;
 
  return (
    <Stack screenOptions={{ headerShown: false }}>
      {isAuthenticated ? (
        <Stack.Screen name="(tabs)" />
      ) : (
        <Stack.Screen name="(auth)" />
      )}
      <Stack.Screen
        name="modal"
        options={{ presentation: "modal" }}
      />
    </Stack>
  );
}

The key pattern is conditional rendering in the root layout based on auth state. This means you never have to manually navigate between auth and app flows. When the auth token appears, the authenticated stack renders automatically. When it disappears (logout, token expiry), the auth stack takes over.

For deep linking, Expo Router handles URL schemes out of the box. I configure push notification handlers to extract the route path from the notification payload and call router.push(). This works reliably for both foreground and background notification taps.

State Management: What Actually Works

I have used MobX, plain Context, Zustand, and Redux Toolkit across different projects. Here is my current recommendation: Redux Toolkit with RTK Query for most production apps.

The reason is not that Redux is the "best" state manager in isolation. It is that RTK Query solves the hardest problem in mobile apps: server state synchronization. Cache invalidation, optimistic updates, automatic refetching, polling — RTK Query handles all of these with a declarative API.

// services/shipments.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
 
export const shipmentsApi = createApi({
  reducerPath: "shipmentsApi",
  baseQuery: fetchBaseQuery({
    baseUrl: API_BASE_URL,
    prepareHeaders: (headers, { getState }) => {
      const token = (getState() as RootState).auth.token;
      if (token) headers.set("Authorization", `Bearer ${token}`);
      return headers;
    },
  }),
  tagTypes: ["Shipment"],
  endpoints: (builder) => ({
    getShipments: builder.query<Shipment[], void>({
      query: () => "/shipments",
      providesTags: ["Shipment"],
    }),
    updateShipmentStatus: builder.mutation<void, { id: string; status: string }>({
      query: ({ id, status }) => ({
        url: `/shipments/${id}/status`,
        method: "PATCH",
        body: { status },
      }),
      invalidatesTags: ["Shipment"],
    }),
  }),
});

For local-only state (theme preference, form drafts, UI toggles), I use a simple Redux slice or Zustand. The point is to keep server state and local state in different systems. Mixing them is where apps become unmaintainable.

In the Truxo app, drivers need to see shipment updates in near real-time. I combine RTK Query polling with push notification-triggered invalidation. When a notification arrives saying a shipment status changed, I invalidate the relevant RTK Query tag, which triggers an automatic refetch. The driver sees the update without pulling to refresh.

Performance Optimization That Matters

Performance problems in React Native fall into three categories: JS thread blocking, excessive re-renders, and list performance. Everything else is noise.

JS Thread Blocking

The most common cause of jank in React Native apps is doing heavy work on the JS thread. Symptoms: slow navigation transitions, unresponsive touch handlers, choppy scrolling.

My rules:

In Simple Life, the encryption and decryption of vault items happens in a Web Worker-like pattern. The UI thread never blocks on crypto operations, even when decrypting dozens of items at once. I wrote more about this architecture in my post on zero-knowledge encryption.

Excessive Re-renders

React DevTools Profiler is your friend. But even before profiling, follow these patterns:

// BAD: Creates a new object every render, causing child re-renders
<ShipmentCard style={{ marginBottom: 16 }} />
 
// GOOD: Stable reference
const cardStyle = useMemo(() => ({ marginBottom: 16 }), []);
<ShipmentCard style={cardStyle} />
 
// BAD: Inline callback causes re-render of memoized child
<Button onPress={() => handlePress(item.id)} />
 
// GOOD: Stable callback
const handleItemPress = useCallback(
  (id: string) => handlePress(id),
  [handlePress]
);

Wrap expensive child components in React.memo. But do not memo everything — the overhead of comparison can exceed the cost of re-rendering for simple components.

List Performance

FlatList with getItemLayout (for fixed-height items) or FlashList from Shopify. I have switched all my projects to FlashList. The performance difference is dramatic for lists with 100+ items.

import { FlashList } from "@shopify/flash-list";
 
<FlashList
  data={shipments}
  renderItem={({ item }) => <ShipmentCard shipment={item} />}
  estimatedItemSize={120}
  keyExtractor={(item) => item.id}
/>

The estimatedItemSize prop is critical. Get it wrong and FlashList falls back to FlatList behavior. Measure your actual item height in development and use that number.

Push Notifications: The Complete Picture

Push notifications are table stakes for production mobile apps. Here is my battle-tested setup:

  1. expo-notifications for local and remote notification handling
  2. EAS Push or Firebase Cloud Messaging as the delivery backend
  3. expo-device to check if running on a physical device (notifications do not work on simulators)

The flow:

// hooks/useNotifications.ts
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import { useEffect, useRef } from "react";
import { router } from "expo-router";
 
export function useNotifications() {
  const notificationListener = useRef<Notifications.Subscription>();
  const responseListener = useRef<Notifications.Subscription>();
 
  useEffect(() => {
    registerForPushNotifications();
 
    // Handle notification received while app is foregrounded
    notificationListener.current =
      Notifications.addNotificationReceivedListener((notification) => {
        // Update badge count, show in-app banner, etc.
      });
 
    // Handle user tapping on notification
    responseListener.current =
      Notifications.addNotificationResponseReceivedListener((response) => {
        const route = response.notification.request.content.data?.route;
        if (route) router.push(route);
      });
 
    return () => {
      if (notificationListener.current)
        Notifications.removeNotificationSubscription(notificationListener.current);
      if (responseListener.current)
        Notifications.removeNotificationSubscription(responseListener.current);
    };
  }, []);
}

The tricky part is not the setup. It is handling edge cases: What happens when the user taps a notification for a shipment that has been deleted? What if the token changes mid-session? What about notification permissions being revoked? Build error handling for all of these from day one.

Over-the-Air Updates with EAS Update

One of Expo's killer features is OTA updates. You can push JavaScript bundle updates to your users without going through app store review. I use this for:

My deployment flow:

# Development
eas update --branch development --message "Fix shipment card layout"
 
# Staging
eas update --branch staging --message "Release candidate 2.3.1"
 
# Production
eas update --branch production --message "v2.3.1 hotfix"

Each branch maps to a release channel. The app checks for updates on launch and downloads them in the background. On the next cold start, the user gets the new version. This has saved me from emergency app store submissions multiple times.

App Store Deployment: Lessons Learned the Hard Way

Apple App Store

Apple review is the final boss of mobile development. Here is what I have learned:

Privacy nutrition labels must be accurate. Apple compares your declared data collection against your actual network traffic. If you use analytics (even basic crash reporting), you need to declare it. Get this wrong and your review will be rejected.

Push notification entitlements. If your app requests notification permissions, Apple wants to know why. Include a clear purpose string and demonstrate the value in your screenshots.

Background modes. If you declare background location (like I did for Truxo Tracker's shipment tracking), you need to justify it with a detailed explanation and a demo video. The first submission took three tries before I got the justification right.

Google Play Store

Google is generally faster and less strict, but they have their own pain points:

Target API level requirements. Google requires targeting the latest Android API level. Each year, you need to update your targetSdkVersion and handle any new permission models.

Data safety form. Similar to Apple's privacy labels but with different categories. Fill it out carefully.

Play Store listing optimization. Your screenshots, description, and short description significantly impact download rates. I test different screenshot layouts for each app.

Testing Strategy for React Native Apps

My testing pyramid for React Native:

  1. Unit tests for utility functions, data transformations, and business logic. Jest with TypeScript. These run in CI on every PR.
  2. Component tests for complex UI components. React Native Testing Library. I test user interactions, not implementation details.
  3. Integration tests for critical flows. I test the auth flow, the main happy path, and any payment-related flows end to end.
  4. Manual testing on real devices before every release. I maintain a device matrix: iPhone SE (small screen), iPhone 15 Pro (latest iOS), Samsung Galaxy A series (budget Android), and Pixel 8 (stock Android).

I do not write snapshot tests. They break on every UI change and provide almost no confidence that anything actually works.

Monitoring and Crash Reporting

Once your app is in production, you need visibility. My stack:

The most important metric is crash-free rate. I target 99.5% or higher. Below that, users notice and leave bad reviews.

Lessons from Real Production Apps

Truxo Tracker

Building the Truxo AI transportation management system taught me about real-time requirements in React Native. Drivers need to see load updates within seconds. I implemented a polling + push notification hybrid that keeps data fresh without destroying battery life. The key insight: poll frequently only when the app is foregrounded, and rely on push notifications for background updates.

Super School

This app taught me about accessibility in React Native. The school serves specially challenged kids, and their parents have varying levels of tech literacy. I implemented larger touch targets (minimum 48x48 dp), high-contrast mode support, and screen reader labels on every interactive element. React Native's accessibilityLabel and accessibilityRole props make this straightforward once you commit to it.

Simple Life

Building an encrypted vault app taught me about secure storage on mobile. expo-secure-store uses the Keychain on iOS and EncryptedSharedPreferences on Android. But it has size limits. For larger encrypted blobs, I use the filesystem with encryption handled at the application layer. The architecture details are in my zero-knowledge encryption post.

What I Would Tell My Past Self

If I could go back to 2021 when I started with React Native, here is what I would say:

  1. Start with Expo. Do not waste months fighting native build tools. Expo's tradeoffs are worth it for 95% of apps.
  2. Invest in TypeScript from day one. The refactoring confidence pays for itself within the first month.
  3. Build a component library early. Do not copy-paste Button styles across screens. Create a design system in your first sprint.
  4. RTK Query or React Query from the start. Manual fetch + useState for API calls does not scale. You will rewrite it all eventually.
  5. Test on real devices weekly. Simulators lie about performance, especially on Android.
  6. Set up CI/CD before your first feature. EAS Build + EAS Submit + GitHub Actions. One hour of setup saves hundreds of hours of manual builds.

React Native in 2025 is a mature, production-ready platform. The tooling has never been better. If you are building cross-platform mobile apps, especially as part of a full stack development career, React Native with Expo is a strong choice.

For more technical deep dives, check out my blog where I write about React Native, TypeScript, backend development, and the realities of building software as a developer in India. You can also explore my portfolio to see the apps I have built and the technologies I work with.

If you are comparing React Native against other cross-platform options, I have shared my detailed analysis in my post on React Native vs Flutter. And for performance-specific deep dives, my guide on React Native app performance covers profiling tools, memory leaks, and optimization techniques in more detail.

Building for production is a different discipline than building for tutorials. I hope this guide helps bridge that gap.

Related Articles

MS
Manjodh Singh Saran

Full Stack Developer · Ludhiana, India

Read more articles · View portfolio