Skip to main content

Expo & React Native Development Notes

Table of Contents

  1. Project Setup
  2. NativeWind (TailwindCSS)
  3. Running the App
  4. Google OAuth
  5. Custom Fonts
  6. Markdown Display
  7. File-Based Routing
  8. Navigation & Params
  9. Layouts & Slots
  10. Image Picker
  11. Push Notifications
  12. Useful Utilities
  13. Developer Tools
  14. Custom Debug Keystore

1. Project Setup

Create an Expo Project

npx create-expo-app@latest my-app
cd my-app
npm run reset-project #To reset the project and remove the boilerplate

Full docs: https://docs.expo.dev/

my-app/
├── app/ # File-based routing (Expo Router)
│ ├── _layout.tsx # Root layout
│ ├── index.tsx # Home screen
│ └── (tabs)/ # Tab group
├── assets/
│ └── fonts/ # Custom font files (.ttf / .otf)
├── components/
├── constants/
├── hooks/
├── react-native.config.js
├── app.json
└── package.json

Then

mkdir assets/audio assets/fonts assets/icons
mkdir auth components constants hooks lib store dummy-data types utils
touch .env

2. NativeWind (TailwindCSS)

NativeWind lets you style React Native components using Tailwind CSS utility classes via the className prop.

Full docs: https://www.nativewind.dev/


3. Running the App

Preferred Workflow: EAS Development Build

An EAS development build is a compiled APK that includes Expo Dev Client. You install it once on your device, then connect it to your local Metro bundler for live code changes — no USB required after the initial install.

# Step 1 — Build and install the development APK (once, or when native deps change)
eas build --profile development --platform android

# Step 2 — Start Metro bundler (every time you develop)
npx expo start --dev-client

After installing the APK, open it on your device. It will show a screen to scan the QR code from expo start or enter your Metro server URL manually. From that point, code changes reflect instantly via fast refresh.

When to rebuild the APK: Only when you add a new native module/package, change app.json native config (plugins, permissions), or change native Android code. Pure JS/TypeScript changes never require a rebuild.

Full docs: https://docs.expo.dev/develop/development-builds/introduction/


Preview Build

Use a preview build to test the final app experience (bundle baked in, no Dev Client, no Metro connection).

eas build --profile preview --platform android
Development BuildPreview Build
JS bundlerConnects to local MetroBaked into APK
Dev menu✅ Available❌ Not available
Fast refresh✅ Yes❌ No
Use caseActive developmentTesting final app
KeystoreEAS development keystoreEAS preview keystore

4. Google OAuth

Prerequisites

  • A project on Google Cloud Console
  • A SHA-1 fingerprint per build profile (Android)
  • A Bundle ID (iOS)

Keystores & SHA-1 Fingerprints (Android)

EAS manages a separate keystore for each build profile (development, preview, production). Each keystore has a unique SHA-1 fingerprint that must be registered in Google Cloud Console for Google Sign-In to work.

# Get the SHA-1 for a specific profile
eas credentials # then select platform → profile → Keystore

Since all EAS build profiles (development, preview, production) share the same keystore, one Android OAuth client in Google Cloud Console is sufficient — just register the single SHA-1 from eas credentials.

Always use eas credentials to get SHA-1 fingerprints — never use a local keystore file with keytool, as EAS builds use keystores stored on Expo's servers, not your machine.

Google Cloud Console Setup

  1. Go to APIs & Services > Credentials > Create Credentials > OAuth Client ID
  2. Create one Android client per build profile — enter your package name (e.g. com.yourname.app) and that profile's SHA-1
  3. Create one Web client — used as the webClientId in your app
  4. For iOS, create an iOS client and enter your Bundle ID

Install the Library

npx expo install @react-native-google-signin/google-signin

Full docs: https://react-native-google-signin.github.io/

Usage

import {
GoogleSignin,
GoogleSigninButton,
statusCodes,
} from "@react-native-google-signin/google-signin";

// Configure once (e.g. in _layout.tsx or App.tsx)
GoogleSignin.configure({
webClientId: "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
});

export default function SignInScreen() {
const signIn = async () => {
try {
await GoogleSignin.hasPlayServices();
const userInfo = await GoogleSignin.signIn();
console.log(userInfo);
} catch (error: any) {
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
console.log("User cancelled sign-in");
} else if (error.code === statusCodes.IN_PROGRESS) {
console.log("Sign-in already in progress");
} else {
console.error(error);
}
}
};

return <GoogleSigninButton onPress={signIn} />;
}

5. Custom Fonts

Step 1 — Add Font Files

Place your .ttf or .otf files in assets/fonts/:

assets/
└── fonts/
├── MyFont-Regular.ttf
└── MyFont-Bold.ttf

Step 2 — Create react-native.config.js

// react-native.config.js
module.exports = {
assets: ["./assets/fonts"],
};
npx react-native-asset

This copies fonts into the native Android/iOS folders automatically.

Step 4 — Use the Font

<Text style={{ fontFamily: "MyFont-Regular" }}>Hello World</Text>

Tip: With Expo, you can also load fonts at runtime using expo-font:

import { useFonts } from "expo-font";

const [fontsLoaded] = useFonts({
"MyFont-Regular": require("./assets/fonts/MyFont-Regular.ttf"),
});

6. Markdown Display

Install

npx expo install react-native-markdown-display

Usage

import Markdown from "react-native-markdown-display";

export default function MarkdownScreen() {
const content = `
# Hello
This is **bold** and _italic_ text.
- Item 1
- Item 2
`;

return <Markdown>{content}</Markdown>;
}

Custom Styles

const markdownStyles = {
heading1: { fontSize: 24, fontWeight: "bold", color: "#333" },
body: { fontSize: 16, lineHeight: 24 },
};

<Markdown style={markdownStyles}>{content}</Markdown>;

7. File-Based Routing

Expo Router uses the file system to define routes, similar to Next.js.

Dynamic Routes

Create a file named [id].tsx inside the app/ folder:

// app/post/[id].tsx
import { useLocalSearchParams } from "expo-router";
import { Text, View } from "react-native";

export default function PostScreen() {
const { id } = useLocalSearchParams();

return (
<View>
<Text>Post ID: {id}</Text>
</View>
);
}

Route Groups (No URL Segment)

Use parentheses to group routes without affecting the URL:

app/
├── (auth)/
│ ├── login.tsx → /login
│ └── register.tsx → /register
└── (tabs)/
├── index.tsx → /
└── profile.tsx → /profile

8. Navigation & Params

import { router } from "expo-router";

// Basic navigation
router.push("/page2");

// With params
router.push({ pathname: "/page2", params: { from: "Home", userId: "123" } });

// Replace current screen (no back button)
router.replace("/login");

// Go back
router.back();

Read Params on the Destination Page

import { useLocalSearchParams } from "expo-router";

export default function Page2() {
const { from, userId } = useLocalSearchParams();

return <Text>Came from: {from}</Text>;
}

Enable typed routes in app.json for autocomplete and safety:

{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}

9. Layouts & Slots

Use <Slot /> in a layout file when you want to wrap screens with shared UI like a Header or Footer without replacing the navigator.

// app/_layout.tsx
import { Slot } from "expo-router";
import { View } from "react-native";
import Header from "@/components/Header";
import Footer from "@/components/Footer";

export default function RootLayout() {
return (
<View style={{ flex: 1 }}>
<Header />
<Slot /> {/* Child screen renders here */}
<Footer />
</View>
);
}

<Stack> replaces the child with a full stack navigator.
<Slot> just renders the matched child screen in place — useful when you want full control of the layout.


10. Image Picker

Install

npx expo install expo-image-picker

Add Permission to app.json

{
"expo": {
"plugins": [
[
"expo-image-picker",
{
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
"cameraPermission": "Allow $(PRODUCT_NAME) to use the camera."
}
]
]
}
}

Usage

import * as ImagePicker from "expo-image-picker";
import { Button, Image, View } from "react-native";
import { useState } from "react";

export default function ImagePickerScreen() {
const [image, setImage] = useState<string | null>(null);

const pickImage = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 1,
});

if (!result.canceled) {
setImage(result.assets[0].uri);
}
};

const takePhoto = async () => {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) return;

const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 1,
});

if (!result.canceled) {
setImage(result.assets[0].uri);
}
};

return (
<View>
<Button title="Pick from Library" onPress={pickImage} />
<Button title="Take Photo" onPress={takePhoto} />
{image && (
<Image source={{ uri: image }} style={{ width: 200, height: 200 }} />
)}
</View>
);
}

11. Push Notifications

Build with EAS First

npm install -g eas-cli
eas build --platform android

Android Setup (Firebase Cloud Messaging — FCM V1)

Step 1 — Create a Firebase Project

  1. Go to Firebase Console
  2. Click Add Project and follow the steps
  3. Register your Android app using your package name (e.g. com.yourname.app)
  4. Download the google-services.json file

Step 2 — Generate a Firebase Service Account Key

  1. In Firebase, go to Project Settings > Service Accounts
  2. Click Generate new private key and download the JSON file

Step 3 — Upload to Expo Credentials

  1. In the Expo dashboard, open your project
  2. Go to Credentials > Android
  3. Upload the downloaded key to FCM V1 Service Account Key

Step 4 — Add google-services.json to Your Project

Place the file in the root or any directory, then reference it in app.json:

{
"expo": {
"android": {
"googleServicesFile": "./google-services.json",
"package": "com.yourname.app"
}
}
}

Step 5 — Install the Notifications Library

npx expo install expo-notifications expo-device

Step 6 — Request Permission & Get Push Token

import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import { useEffect } from "react";
import { Platform } from "react-native";

Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});

export async function registerForPushNotificationsAsync(): Promise<
string | null
> {
if (!Device.isDevice) {
alert("Must use a physical device for push notifications");
return null;
}

const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;

if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}

if (finalStatus !== "granted") {
alert("Permission not granted for push notifications!");
return null;
}

const token = (await Notifications.getExpoPushTokenAsync()).data;
console.log("Expo Push Token:", token);

if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
});
}

return token;
}

iOS Setup

  1. An Apple Developer account is required
  2. EAS will automatically handle APNs certificates when you run eas build --platform ios
  3. Add the following to app.json:
{
"expo": {
"ios": {
"bundleIdentifier": "com.yourname.app"
}
}
}

12. Useful Utilities

Screen Dimensions

import { Dimensions } from "react-native";

const SCREEN_WIDTH = Dimensions.get("window").width;
const SCREEN_HEIGHT = Dimensions.get("window").height;

For dynamic updates on rotation, use the hook instead:

import { useWindowDimensions } from "react-native";

const { width, height } = useWindowDimensions();

Platform Detection

import { Platform } from "react-native";

if (Platform.OS === "android") {
// Android-specific code
} else if (Platform.OS === "ios") {
// iOS-specific code
}

Safe Area

npx expo install react-native-safe-area-context
import { SafeAreaView } from "react-native-safe-area-context";

export default function Screen() {
return <SafeAreaView style={{ flex: 1 }}>{/* content */}</SafeAreaView>;
}

Async Storage

npx expo install @react-native-async-storage/async-storage
import AsyncStorage from "@react-native-async-storage/async-storage";

await AsyncStorage.setItem("key", "value");
const value = await AsyncStorage.getItem("key");
await AsyncStorage.removeItem("key");

13. Developer Tools

ActionHow
Open React Native DevToolsPress j in the terminal
Reload the appPress r in the terminal
Open Expo developer menu (device)Shake the device or press m
Toggle performance monitorExpo menu > Performance Monitor
Inspect elementExpo menu > Show Element Inspector
Open React DevTools standalonenpx react-devtools

Quick Reference — Common Commands

# Create project
npx create-expo-app@latest my-app

# Prebuild (generates native folders)
npx expo prebuild --clean

# Run
npm run android
npm run ios

# Link fonts
npx react-native-asset

# EAS build
eas build --platform android
eas build --platform ios
eas build --platform all

14. Custom Debug Keystore

1. Generate a new debug keystore

Run this once from your home directory (~):

keytool -genkeypair -v \
-keystore debug.keystore \
-alias androiddebugkey \
-keyalg RSA \
-keysize 2048 \
-storepass android \
-keypass android \
-validity 10000

This creates /home/lawal/debug.keystore with a unique SHA-1 fingerprint.

2. Get the SHA-1

keytool -list -v -keystore /home/lawal/debug.keystore -alias androiddebugkey -storepass android -keypass android | grep SHA1

Register this SHA-1 in Firebase/Google Console for any Google services (Sign-In, Maps, etc.).

3. Auto-copy after every prebuild

Add this to package.json scripts:

"prebuild": "expo prebuild --clean && cp /home/lawal/debug.keystore ./android/app/debug.keystore"

This ensures every fresh prebuild uses your custom keystore instead of the default one Expo generates.