[ Blog ] → React Native

Building apps with React Native in 2026

How we build production React Native apps today, from Expo and the New Architecture to a Laravel API, resilient networking, testing and store releases.

Rainer Rodriguez

Co-founder, Nerdify

Oct 20, 2026 · 8 min read

Building an app with React Native in 2026 means starting with Expo, writing TypeScript and React components that render to native iOS and Android views, and shipping one codebase to both stores. Current versions run only on the New Architecture, so the old performance caveats about the JavaScript bridge no longer apply. The work that decides whether the app is good happens around the UI: a stable API, networking that handles bad connections, secure token storage, tests, and a release process for two app stores.

We have built React Native apps for clients like 1Click, whose iOS and Android app runs against the same package-tracking platform we built for its operations, and our engineers have worked inside Grupo Nación's team on its React Native news apps. This is the setup we use for new apps.

Start with Expo

The React Native docs recommend starting new apps with a framework, and the framework they name is Expo. It handles the native build configuration, gives you a large set of maintained native modules (camera, notifications, secure storage, file system), and builds the apps in the cloud with EAS, so nobody on the team needs a perfectly configured Xcode to ship.

bash
npx create-expo-app@latest field-app
cd field-app
npx expo start

Expo stopped being the "limited" option years ago. When we need native code that no module provides, we write a local Expo module in Swift and Kotlin inside the same project, and the rest of the app keeps using Expo's tooling. We see little reason to start a bare React Native project for a client today.

The New Architecture is the only one

The New Architecture became the default in React Native 0.76. Since 0.82 it is the only one: the flags that used to turn it off are ignored, and Expo dropped the legacy architecture in SDK 55. It replaces the old asynchronous bridge with direct calls between JavaScript and native code, renders with the Fabric renderer, and supports React's concurrent features.

For a project starting now, this means every native library you add has to support the New Architecture. Some libraries now require it: Reanimated 4, for example, doesn't run on the legacy architecture at all. We check this before adding a dependency. A library that hasn't been updated in two years is a red flag regardless.

We use Expo Router for navigation. Screens are files in the app directory, and the folder structure is the navigation structure, which makes deep links work without extra configuration:

tsx
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
// app/_layout.tsx
import {Stack} from 'expo-router'

const queryClient = new QueryClient()

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Stack screenOptions={{headerBackTitle: 'Back'}} />
    </QueryClientProvider>
  )
}

A file at app/orders/[id].tsx becomes the order detail screen, reachable from a push notification or a link at /orders/1042.

A backend built for a mobile client

Most of our apps talk to a Laravel API. Mobile clients put more demands on an API than a web front end does, and we design for them from the first endpoint. Old app versions stay installed for months, so the API is versioned and we don't make breaking changes inside a version. Responses are paginated with cursors, so infinite scroll doesn't repeat or skip items. And writes that create something accept an idempotency key, because a request that times out on a bad connection often succeeded on the server.

For authentication, the app exchanges credentials for a Sanctum token, with the device name so the user can see and revoke sessions:

php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use Illuminate\Validation\ValidationException;

Route::post('/v1/tokens', function (Request $request): array {
    $credentials = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required', 'string'],
        'device_name' => ['required', 'string', 'max:255'],
    ]);

    $user = User::query()->where('email', $credentials['email'])->first();

    if ($user === null || ! Hash::check($credentials['password'], $user->password)) {
        throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']);
    }

    return ['token' => $user->createToken($credentials['device_name'], ['orders:read', 'orders:write'])->plainTextToken];
})->middleware('throttle:6,1');

On the device, the token goes into expo-secure-store, which keeps it in the iOS Keychain and, on Android, encrypts it with a key from the Android Keystore. It never goes in AsyncStorage, which is unencrypted.

Networking that survives bad signal

Phones lose connection in elevators, switch between Wi-Fi and cellular, and sit on slow networks. A fetch with no timeout can hang for a long time, and the user stares at a spinner. Recent work on 1Click's app added retries and timeouts to how it talks to the platform, and every app we build now starts with a small API client that has both:

ts · lib/api.ts
import * as SecureStore from 'expo-secure-store'

const baseUrl = process.env.EXPO_PUBLIC_API_URL

export class ApiError extends Error {
  status: number
  body: unknown

  constructor(status: number, body: unknown) {
    super(`API request failed with status ${status}`)
    this.status = status
    this.body = body
  }
}

type ApiInit = Omit<RequestInit, 'headers'> & {headers?: Record<string, string>}

export async function api<T>(path: string, init: ApiInit = {}, attempt = 1): Promise<T> {
  const method = init.method ?? 'GET'
  const canRetry = method === 'GET' || init.headers?.['Idempotency-Key'] !== undefined
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), 10_000)
  const token = await SecureStore.getItemAsync('token')

  try {
    const response = await fetch(`${baseUrl}${path}`, {
      ...init,
      signal: controller.signal,
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        ...(token ? {Authorization: `Bearer ${token}`} : {}),
        ...init.headers,
      },
    })

    if (response.status >= 500 && canRetry && attempt < 3) {
      await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 500))

      return api<T>(path, init, attempt + 1)
    }

    if (!response.ok) {
      throw new ApiError(response.status, await response.json())
    }

    return (await response.json()) as T
  } finally {
    clearTimeout(timeout)
  }
}

The retry only covers server errors on requests that are safe to repeat. GET requests always are. POST requests are safe only when they carry an idempotency key, which is why the backend accepts one. A 422 validation error is never retried, since sending the same data again will fail the same way.

On top of that client we use TanStack Query for reading data. It caches responses, retries failed reads, and gives every screen loading and error states without hand-written effects. Hooked up to React Native's AppState through its focusManager, it also refetches when the app returns to the foreground.

tsx
const {data, isPending, isError, refetch} = useQuery({
  queryKey: ['orders', id],
  queryFn: () => api<Order>(`/v1/orders/${id}`),
})

Lists, images and performance

Many of the performance problems we see in React Native apps are in long lists. FlatList works for short lists. For long or complex ones we use FlashList, which recycles views instead of creating new ones as you scroll. Images go through expo-image, which caches them in memory and on disk, and the API returns sizes close to what the screen needs instead of the original upload.

The work that decides whether the app is good happens around the UI.

We profile on a mid-range Android phone, not on the latest iPhone. A list that scrolls smoothly there will scroll smoothly on a new iPhone.

Testing

Component and logic tests run on Jest with React Native Testing Library, which tests what the user sees and taps instead of component internals. For end-to-end flows we use Maestro, which drives the real app on a simulator or device from short YAML files, and we keep those tests to the flows that would stop the business if they broke: log in, the main task of the app, payment if there is one.

Before every store release, someone installs the build from TestFlight or a Google Play internal track on physical devices, including the oldest OS versions we support. Simulators miss things like push notification permissions, camera behavior and keyboard layouts.

Releases and over-the-air updates

EAS Build produces the store binaries, and EAS Submit uploads them. Store review adds time to every native release, so we plan releases rather than shipping on impulse.

For JavaScript-only fixes, EAS Update delivers the new bundle to installed apps without a store release. We use it for bug fixes and small adjustments, publish to a staging channel first, and roll out to production gradually. Changes to native code or to what the app does still go through store review, both because the binary has to change and because the store rules require it.

bash
eas update --channel production --message "Fix order total rounding"

When React Native is the wrong choice

React Native fits most business apps: forms, lists, maps, payments, notifications, offline caching, camera. We would steer a client elsewhere for a 3D game, an app built mostly around heavy real-time audio or video processing, or a product that only targets one platform and has a team that already writes Swift or Kotlin. For everything else, one TypeScript codebase for both platforms, sharing types and sometimes logic with a React web app, saves a lot of work over two native teams.

Read also · Laravel

API design best practices we use on every Laravel project

Oct 20, 2026 · 9 min read

[ Read → ]

If you are planning an iOS and Android app, our React Native development team builds and maintains apps like these, with the Laravel backend behind them.

Written by

Rainer Rodriguez

Co-founder, Nerdify

Rainer co-founded Nerdify in Managua, Nicaragua, in 2016 and works with the team on the client products it builds and maintains.

Service · React Native development

Need a team that works like this?

More from the blog

[ All articles → ]