DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Expo & React Native Complete Guide for Web Developers (2026)

If you know React and TypeScript, you already know 80% of what you need to build a real mobile app. Expo closes the remaining gap — file-based routing, native module abstractions, cloud builds, and OTA updates without going through App Store review.

Setup

npx create-expo-app@latest my-app --template blank-typescript
cd my-app
npx expo start
Enter fullscreen mode Exit fullscreen mode

Scan the QR code with Expo Go to preview instantly. No Xcode or Android Studio needed during development.

Versions: Expo SDK 52, React Native 0.76, New Architecture enabled by default.

Expo Router: File-Based Routing

Same conventions as Next.js App Router:

app/
  _layout.tsx           ← root Stack navigator
  index.tsx             ← /
  (tabs)/
    _layout.tsx         ← Tab navigator
    index.tsx           ← first tab
    profile.tsx         ← /profile
  (auth)/
    login.tsx           ← /login (outside tabs)
  posts/
    [id].tsx            ← /posts/:id
  +not-found.tsx
Enter fullscreen mode Exit fullscreen mode
// app/_layout.tsx
import { Stack } from 'expo-router'

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="(auth)/login" options={{ presentation: 'modal' }} />
    </Stack>
  )
}
Enter fullscreen mode Exit fullscreen mode
// app/posts/[id].tsx
import { useLocalSearchParams, useRouter, Stack } from 'expo-router'
import { View, Text, Pressable } from 'react-native'

export default function PostDetail() {
  const { id } = useLocalSearchParams<{ id: string }>()
  const router = useRouter()

  return (
    <>
      <Stack.Screen options={{ title: `Post ${id}` }} />
      <View style={{ flex: 1, padding: 16 }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold' }}>Post {id}</Text>
        <Pressable onPress={() => router.back()}>
          <Text>Go Back</Text>
        </Pressable>
      </View>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Web vs React Native: The Key Differences

Web React Native
<div> <View>
<p>, <span> <Text> (all text must be in Text)
<img> <Image> (needs explicit width/height)
map() for lists <FlatList> (virtualized)
<button> <Pressable>
<input> <TextInput>
CSS StyleSheet or NativeWind
localStorage SecureStore (encrypted)

No DOM, no CSS cascade, no window. Flexbox is the default layout system — you don't write display: flex.

Data Fetching with TanStack Query

Identical API to web:

npm install @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode
// hooks/usePosts.ts
import { useQuery } from '@tanstack/react-query'

export function usePosts() {
  return useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await fetch('https://api.yourapp.com/posts')
      if (!res.ok) throw new Error('Failed to fetch')
      return res.json()
    },
    staleTime: 1000 * 60 * 5
  })
}
Enter fullscreen mode Exit fullscreen mode
// FlatList with pull-to-refresh
import { FlatList, RefreshControl, ActivityIndicator } from 'react-native'
import { usePosts } from '~/hooks/usePosts'

export default function HomeScreen() {
  const { data: posts, isLoading, refetch, isRefetching } = usePosts()

  if (isLoading) return <ActivityIndicator size="large" color="#6366f1" />

  return (
    <FlatList
      data={posts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <PostCard post={item} />}
      refreshControl={<RefreshControl refreshing={isRefetching} onRefresh={refetch} />}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

FlatList renders only viewport items — always use it over map() for potentially long lists.

Secure Auth with SecureStore

Never use AsyncStorage for tokens — it's not encrypted:

npx expo install expo-secure-store
Enter fullscreen mode Exit fullscreen mode
// lib/auth.ts
import * as SecureStore from 'expo-secure-store'
import { router } from 'expo-router'

const TOKEN_KEY = 'auth_token'

export const saveToken = (token: string) => SecureStore.setItemAsync(TOKEN_KEY, token)
export const getToken = () => SecureStore.getItemAsync(TOKEN_KEY)
export const deleteToken = () => SecureStore.deleteItemAsync(TOKEN_KEY)

export async function logout() {
  await deleteToken()
  router.replace('/(auth)/login')
}
Enter fullscreen mode Exit fullscreen mode

Protect your tabs layout:

// app/(tabs)/_layout.tsx
import { Redirect } from 'expo-router'
import { useAuth } from '~/hooks/useAuth'

export default function TabLayout() {
  const { isAuthenticated, isLoading } = useAuth()
  if (isLoading) return null
  if (!isAuthenticated) return <Redirect href="/(auth)/login" />
  return <Tabs>{/* ... */}</Tabs>
}
Enter fullscreen mode Exit fullscreen mode

Forms with React Hook Form + Zod

Exact same library as web:

import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Pressable, KeyboardAvoidingView, Platform } from 'react-native'

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
})

type LoginForm = z.infer<typeof loginSchema>

export default function LoginScreen() {
  const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema)
  })

  return (
    // KeyboardAvoidingView prevents keyboard from covering inputs on iOS
    <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
      <Controller
        control={control}
        name="email"
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            placeholder="Email"
            keyboardType="email-address"
            autoCapitalize="none"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
          />
        )}
      />
      <Pressable onPress={handleSubmit(onSubmit)}>
        <Text>Log in</Text>
      </Pressable>
    </KeyboardAvoidingView>
  )
}
Enter fullscreen mode Exit fullscreen mode

Styling: StyleSheet vs NativeWind

StyleSheet (built-in):

import { StyleSheet } from 'react-native'

const styles = StyleSheet.create({
  card: {
    backgroundColor: '#f9fafb',
    borderRadius: 12,
    padding: 16,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    elevation: 3  // Android shadow
  }
})
Enter fullscreen mode Exit fullscreen mode

NativeWind (Tailwind classes on native):

npm install nativewind tailwindcss
Enter fullscreen mode Exit fullscreen mode
import { styled } from 'nativewind'
import { View, Text } from 'react-native'

const StyledView = styled(View)
const StyledText = styled(Text)

function Card({ title }: { title: string }) {
  return (
    <StyledView className="bg-white rounded-xl p-4 mb-3 shadow-sm">
      <StyledText className="text-lg font-semibold text-gray-900">{title}</StyledText>
    </StyledView>
  )
}
Enter fullscreen mode Exit fullscreen mode

EAS: Production Builds and OTA Updates

npm install -g eas-cli
eas login
eas build:configure
eas build --platform all --profile production
eas submit --platform all
Enter fullscreen mode Exit fullscreen mode

OTA updates — push JS changes without App Store review:

eas update --branch production --message "Fix login bug"
Enter fullscreen mode Exit fullscreen mode

Users get the update next time they open the app. Only native code changes (new Expo modules, SDK upgrades) require a new store submission.

Push Notifications

npx expo install expo-notifications expo-device
Enter fullscreen mode Exit fullscreen mode
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'

export async function registerForPushNotifications(): Promise<string | null> {
  if (!Device.isDevice) return null

  const { status } = await Notifications.requestPermissionsAsync()
  if (status !== 'granted') return null

  const token = await Notifications.getExpoPushTokenAsync({
    projectId: process.env.EXPO_PUBLIC_PROJECT_ID!
  })

  return token.data  // send to your backend
}
Enter fullscreen mode Exit fullscreen mode

The Mental Model

React Native gives you native UI components — not a WebView. A <View> becomes a UIView on iOS and android.view.View on Android. The New Architecture (stable in RN 0.76) replaced the async JS bridge with synchronous JSI calls — performance is no longer a valid objection.

The libraries you already use — Zustand, TanStack Query, React Hook Form, Zod — work identically. The surface area that's genuinely different (StyleSheet, FlatList, KeyboardAvoidingView, SecureStore) is learnable in a week.


Full article at stacknotice.com/blog/expo-react-native-complete-guide-2026

Top comments (0)