cometchat-native-expo-patterns
Purpose
Teaches Claude how to integrate CometChat into an Expo managed workflow project. Covers:
- Installing the full peer-dependency set (not just the UI Kit)
- Configuring
app.jsonpermissions for iOS + Android - Wiring the provider chain in
App.tsxwith all four wrappers - Optional calling SDK setup
- Env vars via
expo-constantsor.env - Expo Router subsection (file-based routing)
- Prebuild + run cadence
Read cometchat-native-core first (init/login/wrapper chain + anti-patterns), then cometchat-native-components (prop reference), then cometchat-native-placement (where chat goes).
Ground truth: docs/ui-kit/react-native/expo-integration.mdx, expo-conversation.mdx, expo-one-to-one-chat.mdx, expo-tab-based-chat.mdx, and examples/SampleAppExpo/.
Use this skill when
- Project has
expoinpackage.jsondependencies app.json/app.config.jsexists at the rootpackage.jsonmainfield referencesexpo(e.g."main": "index.js"with an Expo-style entry)- The user says "Expo", "Expo Router", "managed workflow", or "EAS"
Do NOT use this skill when:
- The project has an
ios/+android/folder at the root (that's bare RN → usecometchat-native-bare-patterns) - The user says "bare React Native", "React Native CLI", or "ejected"
Hard prerequisite — Expo Go is NOT supported
The CometChat UI Kit depends on native modules that can't be shimmed. This means:
- Expo Go won't load your app — you'll see "Main module field cannot be resolved" or similar
- You must build a development client (
eas build --profile developmentorexpo run:ios/expo run:android) - Or install in a plain Expo simulator via prebuild
The first build can take 5-15 minutes. Subsequent runs are fast via the dev client.
Before integrating, confirm the user has either:
eas-cliinstalled and an EAS account, OR- Xcode + Android Studio for local prebuilds
If neither, stop and ask them to set one up. Don't waste their time installing packages that won't run.
Step 1 — Install dependencies
The UI Kit has a long peer-dep tail. Install them all in one shot so Expo's resolver doesn't miss a native module during prebuild:
# Core SDK + UI Kit
npm install @cometchat/chat-sdk-react-native
npm install @cometchat/chat-uikit-react-native
# Required peer deps (all natively-linked)
npx expo install \
@react-native-async-storage/async-storage \
@react-native-clipboard/clipboard \
@react-native-community/datetimepicker \
react-native-gesture-handler \
react-native-localize \
react-native-safe-area-context \
react-native-svg \
react-native-video
# dayjs + punycode — no native code but required by the kit
npm install dayjs punycode
Why npx expo install for the natively-linked deps? expo install picks versions compatible with the project's Expo SDK. Using npm install directly can land incompatible versions that break prebuild.
Optional — calling SDK
If the user's flow includes voice / video calls (the cometchat-native-features skill's § Calls gates this):
npm install @cometchat/calls-sdk-react-native
npx expo install \
@react-native-community/netinfo \
react-native-background-timer \
react-native-callstats \
react-native-webrtc
Skip these until the user actually wants calls. Adding WebRTC to an Expo project bloats the prebuild and requires extra permissions — don't speculatively enable it.
Step 2 — Configure app.json
Add iOS + Android permissions so the kit's attachments, camera, mic, and media features work. Merge into existing expo.ios.infoPlist / expo.android.permissions — do not replace anything the user already has.
{
"expo": {
"ios": {
"infoPlist": {
"NSCameraUsageDescription": "Allow camera access to send photos and make video calls",
"NSMicrophoneUsageDescription": "Allow microphone access to send voice messages and make calls",
"NSPhotoLibraryUsageDescription": "Allow photo library access to send photos",
"NSPhotoLibraryAddUsageDescription": "Allow saving photos from chat to your library"
}
},
"android": {
"permissions": [
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.VIBRATE",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO"
]
}
}
}
Permission-string best practice: the iOS Usage strings show in the system prompt when iOS asks the user for permission — write them as user-facing copy, not developer notes. "Camera access for video calls" is fine; "for media upload" isn't a real reason a user would accept.
If the project uses app.config.js or app.config.ts
Merge the same fields into the exported config. Don't switch the project from JS to JSON unless the user asks — respect their setup.
Step 3 — Wire the provider chain in App.tsx
Expo projects use the same four-wrapper chain as bare RN (see cometchat-native-core § 3). The difference is the entry file — Expo uses App.tsx (or index.ts + registerRootComponent) rather than bare's index.js + AppRegistry.
// App.tsx
import "react-native-gesture-handler"; // MUST be the first import
import React from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { CometChatThemeProvider } from "@cometchat/chat-uikit-react-native";
import { CometChatProvider } from "./src/providers/CometChatProvider";
import { AppNavigator } from "./src/navigation/AppNavigator";
import Constants from "expo-constants";
const extra = Constants.expoConfig?.extra ?? {};
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<CometChatThemeProvider>
<CometChatProvider
appId={extra.COMETCHAT_APP_ID}
region={extra.COMETCHAT_REGION}
authKey={extra.COMETCHAT_AUTH_KEY}
uid="cometchat-uid-1" // dev mode only
>
<AppNavigator />
</CometChatProvider>
</CometChatThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
The CometChatProvider itself is defined per cometchat-native-core § 6 — reuse that implementation; don't invent another one.
import "react-native-gesture-handler" must be first
Even before React. Expo's entry-file hot reload otherwise loses the gesture handler patch and the composer / bottom-sheet gestures silently disable.
// At the top of App.tsx:
import "react-native-gesture-handler";
// THEN everything else
import React from "react";
Step 4 — Env vars
Two options; pick one based on what the project already uses.
Option A — app.json extra + expo-constants (simple, recommended)
{
"expo": {
"extra": {
"COMETCHAT_APP_ID": "YOUR_APP_ID",
"COMETCHAT_REGION": "us",
"COMETCHAT_AUTH_KEY": "YOUR_AUTH_KEY"
}
}
}
Read via expo-constants:
import Constants from "expo-constants";
const { COMETCHAT_APP_ID, COMETCHAT_REGION, COMETCHAT_AUTH_KEY } = Constants.expoConfig?.extra ?? {};
Warning: these values end up in the client bundle. Never put REST_API_KEY or any server-side secret in expo.extra — use a backend (see cometchat-native-production).
Option B — .env + expo-dotenv / SDK-native .env support
Expo SDK 50+ supports .env out of the box via EXPO_PUBLIC_* prefix:
# .env
EXPO_PUBLIC_COMETCHAT_APP_ID=your_app_id
EXPO_PUBLIC_COMETCHAT_REGION=us
EXPO_PUBLIC_COMETCHAT_AUTH_KEY=your_auth_key
Read directly via process.env.EXPO_PUBLIC_COMETCHAT_APP_ID anywhere in your app. Any variable WITHOUT the EXPO_PUBLIC_ prefix is ONLY available in app.config.js / server scripts, not bundled — useful for REST API keys in backend-only code.
Which to choose
- If the project already has
.env— Option B. - If the project hasn't set up env vars at all — Option A (one file, no prefix rules).
- Never mix both for the same variable — pick one place.
Step 5 — Expo Router subsection
Expo Router is a file-based alternative to @react-navigation/*. If the project has app/ instead of (or alongside) src/screens/, they're using Expo Router.
Detect Expo Router by checking package.json for expo-router in dependencies, and app.json for the "expo-router" plugin.
5a — Router entry (app/_layout.tsx)
In Expo Router, the app/_layout.tsx file is the root layout — wrap the provider chain here instead of in App.tsx.
// app/_layout.tsx
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { CometChatThemeProvider } from "@cometchat/chat-uikit-react-native";
import { CometChatProvider } from "../src/providers/CometChatProvider";
import { Slot } from "expo-router";
import Constants from "expo-constants";
const extra = Constants.expoConfig?.extra ?? {};
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<CometChatThemeProvider>
<CometChatProvider
appId={extra.COMETCHAT_APP_ID}
region={extra.COMETCHAT_REGION}
authKey={extra.COMETCHAT_AUTH_KEY}
uid="cometchat-uid-1"
>
<Slot /> {/* Expo Router renders child routes here */}
</CometChatProvider>
</CometChatThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
5b — Conversations + message route
app/
_layout.tsx ← provider chain (above)
index.tsx ← home (could be the conversations list)
messages/
[uid].tsx ← dynamic route, one chat per uid
// app/index.tsx
import { CometChatConversations, CometChatUiKitConstants } from "@cometchat/chat-uikit-react-native";
import { router } from "expo-router";
export default function Home() {
return (
<CometChatConversations
onItemPress={(conversation) => {
const entity = conversation.getConversationWith();
const type = conversation.getConversationType();
if (type === CometChatUiKitConstants.ConversationTypeConstants.user) {
router.push(`/messages/${(entity as any).getUid()}`);
} else {
router.push(`/messages/group-${(entity as any).getGuid()}`);
}
}}
/>
);
}
// app/messages/[uid].tsx
import { useLocalSearchParams, router } from "expo-router";
import { useEffect, useState } from "react";
import { View } from "react-native";
import { CometChat } from "@cometchat/chat-sdk-react-native";
import {
CometChatMessageHeader,
CometChatMessageList,
CometChatMessageComposer,
} from "@cometchat/chat-uikit-react-native";
export default function ChatScreen() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const [user, setUser] = useState<CometChat.User | null>(null);
useEffect(() => {
if (!uid) return;
// Simple UID routing; group routing encodes differently in the index example above
CometChat.getUser(uid).then(setUser).catch(() => setUser(null));
}, [uid]);
if (!user) return null;
return (
<View style={{ flex: 1 }}>
<CometChatMessageHeader user={user} onBack={() => router.back()} showBackButton />
<CometChatMessageList user={user} hideReplyInThreadOption />
<CometChatMessageComposer user={user} />
</View>
);
}
5c — Tabs in Expo Router (if the project uses them)
app/
_layout.tsx
(tabs)/
_layout.tsx ← Tabs layout
chats.tsx
users.tsx
groups.tsx
calls.tsx
messages/
[uid].tsx
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
export default function TabsLayout() {
return (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="chats" options={{ title: "Chats" }} />
<Tabs.Screen name="users" options={{ title: "Users" }} />
<Tabs.Screen name="groups" options={{ title: "Groups" }} />
<Tabs.Screen name="calls" options={{ title: "Calls" }} />
</Tabs>
);
}
Each tab file renders a single list component (CometChatConversations, CometChatUsers, etc. — see cometchat-native-placement § 2 Bottom tab for the component choices) and pushes to /messages/[uid] on press.
Expo Router gotchas
unstable_settingsin_layout.tsxcan break deep linking if set incorrectly. Leave it alone unless you know you need it.- Navigation between stack + tabs:
router.push("/messages/abc")works from a tab.router.back()returns to the tab. No extra configuration needed. - Search params: use
useLocalSearchParams()(notuseSearchParams()— that's web-only).
Step 6 — Prebuild + run
Before the first run, Expo needs to generate native projects:
npx expo prebuild
Then run on the platform:
# iOS (requires Xcode)
npx expo run:ios
# Android (requires Android Studio)
npx expo run:android
# Or EAS for cloud builds
eas build --profile development --platform ios
Subsequent runs use npx expo start with the dev client — no rebuild needed unless native deps change.
When to rebuild vs. reload:
- Changed JS / JSX / TSX → no rebuild, just
rto reload or save in Fast Refresh - Added / removed a native dependency →
npx expo prebuild --clean && npx expo run:ios - Changed
app.jsonpermissions or plugins →npx expo prebuild --clean
Step 7 — Verify integration
npx tsc --noEmit # TypeScript check
Then in the running app:
- Open the chat screen you wired
- Check that the keyboard opens when you tap the composer (gesture handler working)
- Tap the "+" attachment button — the action sheet should slide up (bottom sheet working)
- Send a message — it should appear immediately
If any of these don't work, see cometchat-native-troubleshooting.
Hard rules
- No Expo Go. The user's project must use development builds. Detect early and tell the user if they're on Expo Go.
import "react-native-gesture-handler"is the first line of the entry file (App.tsxorapp/_layout.tsxfor Expo Router). Not second. Not after any React import.- Install all peer deps via
npx expo install, notnpm install, for native modules. Expo's resolver picks SDK-compatible versions. - Never commit
REST_API_KEY(or any server-side secret) toapp.json extra— it ends up in the client bundle. Use a server endpoint (seecometchat-native-production). - Merge permissions into
app.json, don't replace. The user may already have permissions for other libraries; wipe them out and their other features break. npx expo prebuild --cleanafter changingapp.jsonpermissions or adding native deps. Without it, iOS + Android see the old config.- Every
<CometChatMessageList>must includehideReplyInThreadOptionunless you're also wiring a full thread panel (seecometchat-native-placement§ Hard rule 5). - The four-wrapper chain goes at the app root, not per-screen (see
cometchat-native-core§ 3). For Expo Router, that'sapp/_layout.tsx. For plain Expo, that'sApp.tsx.
Common questions
Q: Can I use npx create-expo-app --template tabs?
Yes — the tabs template already has Expo Router set up. Just add the provider chain in app/_layout.tsx per § 5a and replace a tab's content with a CometChat component.
Q: Can I use SDK ≤49?
Expo SDK 49 may work but the CometChat peer deps target 50+ conventions. If the user is stuck on an older SDK, ask them to upgrade — or fall back to bare RN via npx expo prebuild + cometchat-native-bare-patterns.
Q: I'm seeing "Main module field cannot be resolved" when opening Expo Go.
That's the "Expo Go doesn't support native modules" error. Build a dev client: eas build --profile development or npx expo run:ios.
Q: My app crashes on first push-notification receive.
Push notifications need additional setup (APNs + FCM + maybe expo-notifications). Out of scope for this skill — see cometchat-native-troubleshooting § Push notifications.
Skill routing reference
| Skill | When to route |
|---|---|
cometchat-native-core |
Init / login / wrapper chain / anti-patterns |
cometchat-native-components |
Component prop reference |
cometchat-native-placement |
Where chat goes (stack / tabs / modal / bottom sheet / embedded) |
cometchat-native-expo-patterns |
This skill — Expo managed workflow specifics |
cometchat-native-bare-patterns |
Bare RN (pod install, native modules, privacy manifest) |
cometchat-native-features |
Calls, extensions, AI |
cometchat-native-theming |
Theme customization |
cometchat-native-customization |
Text formatters, events, custom views |
cometchat-native-production |
Server-side auth tokens + user management |
cometchat-native-troubleshooting |
Prebuild failures, Expo Go errors, keyboard issues, blank chat |