Expo & React Native Development Notes
Table of Contents
- Project Setup
- NativeWind (TailwindCSS)
- Running the App
- Google OAuth
- Custom Fonts
- Markdown Display
- File-Based Routing
- Navigation & Params
- Layouts & Slots
- Image Picker
- Push Notifications
- Useful Utilities
- Developer Tools
- 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/
Recommended Project Structure
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.jsonnative 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 Build | Preview Build | |
|---|---|---|
| JS bundler | Connects to local Metro | Baked into APK |
| Dev menu | ✅ Available | ❌ Not available |
| Fast refresh | ✅ Yes | ❌ No |
| Use case | Active development | Testing final app |
| Keystore | EAS development keystore | EAS 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 credentialsto get SHA-1 fingerprints — never use a local keystore file withkeytool, as EAS builds use keystores stored on Expo's servers, not your machine.
Google Cloud Console Setup
- Go to APIs & Services > Credentials > Create Credentials > OAuth Client ID
- Create one Android client per build profile — enter your package name (e.g.
com.yourname.app) and that profile's SHA-1 - Create one Web client — used as the
webClientIdin your app - For iOS, create an iOS client and enter your Bundle ID
Install the Library
npx expo install @react-native-google-signin/google-signin
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"],
};
Step 3 — Link the Assets
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
Navigate to a Route
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>;
}
Typed Routes (Recommended)
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
- Go to Firebase Console
- Click Add Project and follow the steps
- Register your Android app using your package name (e.g.
com.yourname.app) - Download the
google-services.jsonfile
Step 2 — Generate a Firebase Service Account Key
- In Firebase, go to Project Settings > Service Accounts
- Click Generate new private key and download the JSON file
Step 3 — Upload to Expo Credentials
- In the Expo dashboard, open your project
- Go to Credentials > Android
- 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
- An Apple Developer account is required
- EAS will automatically handle APNs certificates when you run
eas build --platform ios - 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
| Action | How |
|---|---|
| Open React Native DevTools | Press j in the terminal |
| Reload the app | Press r in the terminal |
| Open Expo developer menu (device) | Shake the device or press m |
| Toggle performance monitor | Expo menu > Performance Monitor |
| Inspect element | Expo menu > Show Element Inspector |
| Open React DevTools standalone | npx 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.