Imported from cometchat/cometchat-skills (
skills/cometchat-native-expo-patterns/SKILL.md). Install upstream withnpx skills add cometchat/cometchat-skills --skill cometchat-native-expo-patterns. Copyright stays with the author (MIT).
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/. Official docs: https://www.cometchat.com/docs/ui-kit/react-native/overview · Docs MCP: claude mcp add --transport http cometchat-docs https://www.cometchat.com/docs/mcp (or fetch the URL directly without MCP).
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@^4
npm install @cometchat/chat-uikit-react-native@^5
# 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
# Navigation — REQUIRED if you follow cometchat-native-placement's stack pattern
# (createNativeStackNavigator + NavigationContainer). Not transitive — add them:
npx expo install \
@react-navigation/native \
@react-navigation/native-stack \
react-native-screens \
expo-constants
expo-constantsis also imported directly by Step 3 / Step 4 Option A (import Constants from "expo-constants"). It ships transitively with Expo, but importing it directly is a direct-dependency contract — theexpo installline above lists it explicitly so a fresh install doesn't break.
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@^5 \
react-native-url-polyfill \
react-native-performance \
valibot
npx expo install \
@react-native-community/netinfo \
react-native-background-timer \
react-native-callstats \
react-native-webrtc
react-native-url-polyfill,react-native-performance, andvalibotare not in the calls-sdkpeerDependenciesarray — but the calls-sdk'sdist/polyfills/browser.jsimports them at module top, so Metro fails the bundle without them. Omitting any of the three yieldsUnable to resolve module …at startup with no app render. (Validated 2026-05-26 on@cometchat/calls-sdk-react-native@5.0.0+ Expo SDK 56.)
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, SafeAreaView } from "react-native-safe-area-context";
import { CometChatThemeProvider, CometChatI18nProvider } 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>
<CometChatI18nProvider>
<CometChatProvider
appId={extra.COMETCHAT_APP_ID}
region={extra.COMETCHAT_REGION}
authKey={extra.COMETCHAT_AUTH_KEY}
uid="cometchat-uid-1" // dev mode only
>
<SafeAreaView edges={["top", "bottom"]} style={{ flex: 1 }}>
<AppNavigator />
</SafeAreaView>
</CometChatProvider>
</CometChatI18nProvider>
</CometChatThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
The provider chain matches the kit sample app (examples/SampleApp/App.tsx): CometChatI18nProvider sits inside CometChatThemeProvider, and a root SafeAreaView edges={["top", "bottom"]} wraps the navigator. 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).
⚠️ Dev-client manifest caching trap (validated 2026-05-14). Constants.expoConfig?.extra reads from the manifest the dev client baked in at expo prebuild time — NOT from the live app.json. Editing app.json → expo.extra and reloading the app does NOT pick up new values; the dev client keeps serving the prebuild-time snapshot. Two workarounds:
- For dev iteration on credentials: hardcode in
src/config/*.ts(Metro hot-bundles source changes immediately):// src/config/cometchat.ts export const COMETCHAT_APP_ID = "..."; export const COMETCHAT_REGION = "us"; export const COMETCHAT_AUTH_KEY = "..."; - For one-shot setup or production: after every
app.json → extrachange, run
to regenerate the manifest snapshot.npx expo prebuild --clean && npx expo run:android
Silent failure mode: app reads stale App ID / Auth Key / Region → login fails with cryptic errors (User not found, region mismatch). Hit on the rn-existing cohort 2026-05-14; spent ~30 min before realizing the manifest cache was serving an old App ID.
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, SafeAreaView } from "react-native-safe-area-context";
import { CometChatThemeProvider, CometChatI18nProvider } 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>
<CometChatI18nProvider>
<CometChatProvider
appId={extra.COMETCHAT_APP_ID}
region={extra.COMETCHAT_REGION}
authKey={extra.COMETCHAT_AUTH_KEY}
uid="cometchat-uid-1"
>
<SafeAreaView edges={["top", "bottom"]} style={{ flex: 1 }}>
<Slot /> {/* Expo Router renders child routes here */}
</SafeAreaView>
</CometChatProvider>
</CometChatI18nProvider>
</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 { Platform, 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}
keyboardAvoidingViewProps={Platform.OS === "android" ? {} : { behavior: "padding" }}
/>
</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
# ⚠️ Plain `npx tsc --noEmit` will report DOZENS of errors that are NOT yours.
# The kit (@cometchat/chat-uikit-react-native) ships raw uncompiled .tsx and
# points "types" at src/index, so tsc compiles the kit's own source into your
# program. `skipLibCheck` (only skips .d.ts) and `exclude: [node_modules]`
# (imported files are still checked) do NOT silence it. Verified on a clean
# Expo SDK 56 + kit 5.3.7 + chat-sdk 4.0.24 install: 32 kit-internal errors.
#
# PASS CRITERION = zero errors OUTSIDE node_modules. Filter the kit noise:
npx tsc --noEmit 2>&1 | grep -v node_modules | grep 'error TS'
# ^ EMPTY output = your integration type-checks clean (PASS).
# Any line here = a real error in YOUR code — fix it.
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 |
Visual Builder integration (v4.3)
If the customer picks Visually in dispatcher Step 3.1, the Expo recipe diverges from the standard provider chain. Skills runs cometchat builder export --platform react-native --json to emit src/config/{store.ts, config.json} (the Zustand-backed config store + 7-field envelope JSON), then patches App.tsx with useConfig + theme derivation.
Full recipe lives in cometchat-native-core §"Visual Builder integration". Expo-specific notes:
- Use
npx expo installfor the deps (not rawnpm install) — Expo SDK pins compatible versions. - Env via
process.env.EXPO_PUBLIC_COMETCHAT_*(no extra Babel plugin needed; Expo handles this). - Required additional deps beyond the standard provider set:
zustand,@react-native-async-storage/async-storage. useConfig(s => s.settings.style)— note the selector takesAppConfigdirectly, NOTs.config.settings.style(the.config.wrapper is internal to the Zustand store — Finding F6, 2026-05-21).- Envelope is
settings: { chatFeatures, callFeatures, layout, style, noCode }— theme tokens live undersettings.style(there's nosettings.theme/settings.agent); the engagement feature group ischatFeatures.deeperUserEngagement(notdeeperEngagement). See core §"Feature flag access". - A2 smoke validated: Metro bundles 10.8 MB iOS export clean on Expo SDK 54.
If the customer picks In code, ignore this section — the standard four-wrapper chain + provider pattern from §"Provider chain" applies.