Agent skill
expo-anton-abyzov-specweave
Expo and React Native New Architecture expert. Expo SDK 52+, Fabric, TurboModules, JSI, EAS Build/Submit, Expo Router, Expo Modules API, OTA updates, push notifications. Use for Expo projects, managed workflow, EAS pipelines, or migrating from bare RN.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/expo-anton-abyzov-specweave
SKILL.md
Expo / React Native New Architecture Expert
Comprehensive expertise in Expo SDK 52+, the managed workflow, and the React Native New Architecture (Fabric, TurboModules, JSI). Covers the full lifecycle from project creation through App Store and Play Store submission using EAS.
Fetching Current Documentation
Before providing version-specific guidance, verify current versions. Expo SDK releases every ~3 months and React Native every ~8 weeks. Static version numbers go stale quickly.
For library documentation, use WebSearch or install Context7 manually: claude plugin install context7@claude-plugins-official
Expo SDK 52+ and the Managed Workflow
Project Creation
# Create a new Expo project (always use latest)
npx create-expo-app@latest MyApp
cd MyApp
# Start development server
npx expo start
# Create with a specific template
npx create-expo-app@latest MyApp --template tabs
npx create-expo-app@latest MyApp --template blank-typescript
Managed vs Bare Workflow
| Aspect | Managed Workflow | Bare Workflow |
|---|---|---|
| Native code | Handled by Expo | You manage it |
| Build system | EAS Build | Xcode / Gradle |
| Config | app.json / app.config.ts |
Native project files |
| Updates | OTA via expo-updates | Manual or CodePush |
| Recommended | Yes (for 95% of apps) | Only when necessary |
Key principle: Stay managed as long as possible. Use config plugins and Expo Modules API instead of ejecting.
app.config.ts (Dynamic Configuration)
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'MyApp',
slug: 'my-app',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
scheme: 'myapp',
newArchEnabled: true,
ios: {
bundleIdentifier: 'com.example.myapp',
supportsTablet: true,
infoPlist: {
NSCameraUsageDescription: 'Camera access for profile photos',
},
},
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
},
package: 'com.example.myapp',
permissions: ['CAMERA', 'ACCESS_FINE_LOCATION'],
},
plugins: [
'expo-router',
'expo-notifications',
['expo-camera', { cameraPermission: 'Allow camera access' }],
],
});
React Native New Architecture
Fabric (New Rendering System)
Fabric replaces the old UIManager bridge with a synchronous, C++ rendering pipeline.
// Fabric is enabled by default in Expo SDK 52+
// In app.json / app.config.ts:
{
"expo": {
"newArchEnabled": true
}
}
Benefits of Fabric:
- Synchronous layout calculations (no bridge delay)
- Concurrent rendering support (React 18 features)
- Shared C++ core between iOS and Android
- Better gesture handling and animations
TurboModules
TurboModules replace the old Native Modules system with lazy-loaded, type-safe native interfaces.
// specs/NativeDeviceInfo.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getDeviceModel(): string;
getBatteryLevel(): Promise<number>;
getStorageInfo(): Promise<{ total: number; free: number }>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeDeviceInfo');
Key advantages over old Native Modules:
- Lazy initialization (loaded only when first accessed)
- Type-safe bridge via codegen
- Synchronous method calls possible via JSI
JSI (JavaScript Interface)
JSI provides direct communication between JS and native code without JSON serialization.
// C++ JSI host object example
class DeviceInfoHostObject : public jsi::HostObject {
public:
jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override {
auto propName = name.utf8(rt);
if (propName == "model") {
return jsi::String::createFromUtf8(rt, getDeviceModel());
}
return jsi::Value::undefined();
}
};
Expo Monorepo Setup
Monorepo with Expo (Workspaces)
Expo supports monorepos but requires explicit Metro configuration. Metro does NOT resolve workspace packages or follow symlinks by default.
Step-by-Step Setup
1. Root package.json
{
"private": true,
"workspaces": ["apps/*", "packages/*"]
}
2. Shared Package
// packages/shared/package.json
{
"name": "@myapp/shared",
"main": "src/index.ts", // Point to SOURCE, not dist — Metro transpiles
"types": "src/index.ts"
}
3. Mobile App Dependencies
// apps/mobile/package.json
{
"dependencies": {
"@myapp/shared": "*" // workspace:* for pnpm
}
}
4. Metro Config (Critical)
// apps/mobile/metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const monorepoRoot = path.resolve(__dirname, '../..');
const config = getDefaultConfig(__dirname);
// Watch the entire monorepo
config.watchFolders = [monorepoRoot];
// Resolve node_modules from both app and root (for hoisted deps)
config.resolver.nodeModulesPaths = [
path.resolve(__dirname, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
// Prevent duplicate React/RN instances (crashes at runtime)
config.resolver.extraNodeModules = {
'react': path.resolve(__dirname, 'node_modules/react'),
'react-native': path.resolve(__dirname, 'node_modules/react-native'),
'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
};
// Enable symlink resolution (workspace packages are symlinked)
config.resolver.unstable_enableSymlinks = true;
module.exports = config;
5. TypeScript Path Aliases (Editor Only)
// apps/mobile/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@myapp/shared": ["../../packages/shared/src"],
"@myapp/shared/*": ["../../packages/shared/src/*"]
}
}
}
Important: tsconfig.json paths only affect the editor and tsc. Metro uses its own resolver configured in metro.config.js.
EAS Build with Monorepos
// apps/mobile/eas.json
{
"build": {
"production": {
"node": "20.0.0"
}
}
}
EAS Build automatically detects monorepo structure. If using pnpm:
# Set install command for EAS
eas secret:create --name EAS_BUILD_INSTALL_COMMAND --value "pnpm install --frozen-lockfile"
For yarn workspaces, EAS uses yarn install by default — no extra config needed.
Troubleshooting Monorepo Issues
| Error | Fix |
|---|---|
Unable to resolve module @myapp/shared |
Add watchFolders: [monorepoRoot] to metro.config.js |
Unable to resolve module react (duplicate) |
Set extraNodeModules.react to app's copy |
| Module found by TS but crashes at runtime | Configure Metro resolver (tsconfig paths ≠ Metro) |
ENOENT errors on workspace packages |
Set resolver.unstable_enableSymlinks: true |
| EAS Build fails with missing packages | Verify workspace setup, add EAS_BUILD_INSTALL_COMMAND for pnpm |
EAS Build and EAS Submit
EAS Configuration
// eas.json
{
"cli": { "version": ">= 12.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true }
},
"preview": {
"distribution": "internal",
"ios": { "resourceClass": "m-medium" }
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "1234567890",
"appleTeamId": "TEAM_ID"
},
"android": {
"serviceAccountKeyPath": "./google-services-key.json",
"track": "production"
}
}
}
}
Build Commands
# Development build (includes dev client)
eas build --profile development --platform ios
eas build --profile development --platform android
# Preview build (internal distribution)
eas build --profile preview --platform all
# Production build
eas build --profile production --platform all
# Submit to stores
eas submit --platform ios --latest
eas submit --platform android --latest
# Build and submit in one step
eas build --profile production --platform ios --auto-submit
EAS Environment Variables
# Set secrets (never commit these)
eas secret:create --name SENTRY_DSN --value "https://..." --scope project
eas secret:create --name API_KEY --value "sk-..." --scope project
# List secrets
eas secret:list
Expo Router (File-Based Routing)
Directory Structure
app/
_layout.tsx # Root layout (providers, auth)
index.tsx # Home screen (/)
(tabs)/
_layout.tsx # Tab navigator
home.tsx # /home tab
profile.tsx # /profile tab
(auth)/
_layout.tsx # Auth group layout
login.tsx # /login
register.tsx # /register
settings/
_layout.tsx # Stack navigator for settings
index.tsx # /settings
[id].tsx # /settings/:id (dynamic route)
[...missing].tsx # 404 catch-all
Root Layout with Providers
// app/_layout.tsx
import { Stack } from 'expo-router';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import { ThemeProvider } from '../providers/ThemeProvider';
import { AuthProvider } from '../providers/AuthProvider';
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="settings" options={{ presentation: 'modal' }} />
</Stack>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
Navigation Patterns
import { router, useLocalSearchParams, Link } from 'expo-router';
// Imperative navigation
router.push('/settings/123');
router.replace('/home');
router.back();
router.navigate('/profile');
// Typed params
const { id } = useLocalSearchParams<{ id: string }>();
// Declarative link
<Link href="/settings/456" asChild>
<Pressable><Text>Go to Settings</Text></Pressable>
</Link>
Expo Modules API
Build native modules that work with the managed workflow without ejecting.
// modules/my-module/index.ts
import { NativeModule, requireNativeModule } from 'expo-modules-core';
interface MyModuleEvents {
onDataReceived: { data: string };
}
declare class MyModuleType extends NativeModule<MyModuleEvents> {
greet(name: string): string;
fetchDataAsync(): Promise<string>;
}
export default requireNativeModule<MyModuleType>('MyModule');
// modules/my-module/ios/MyModule.swift
import ExpoModulesCore
public class MyModule: Module {
public func definition() -> ModuleDefinition {
Name("MyModule")
Function("greet") { (name: String) -> String in
return "Hello, \(name)!"
}
AsyncFunction("fetchDataAsync") { (promise: Promise) in
DispatchQueue.global().async {
let result = performExpensiveWork()
promise.resolve(result)
}
}
Events("onDataReceived")
}
}
State Management
Zustand (Recommended for Most Apps)
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface AuthStore {
token: string | null;
user: User | null;
login: (token: string, user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
token: null,
user: null,
login: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => AsyncStorage),
}
)
);
Jotai (Atomic State)
import { atom, useAtom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';
const storage = createJSONStorage<string>(() => AsyncStorage);
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light', storage);
const userAtom = atom<User | null>(null);
const isLoggedInAtom = atom((get) => get(userAtom) !== null);
OTA Updates with expo-updates
// app.config.ts
export default {
updates: {
url: 'https://u.expo.dev/your-project-id',
fallbackToCacheTimeout: 0,
checkAutomatically: 'ON_LOAD',
},
runtimeVersion: {
policy: 'appVersion', // or 'fingerprint' for auto-detection
},
};
// Manual update check
import * as Updates from 'expo-updates';
async function checkForUpdates() {
if (__DEV__) return; // Skip in development
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
}
# Publish OTA update
eas update --branch production --message "Fix checkout bug"
# Preview channel mapping
eas channel:edit production --branch production
eas channel:edit preview --branch staging
Push Notifications with expo-notifications
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) return null;
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return null;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;
return token;
}
Deep Linking and Universal Links
// app.config.ts
export default {
scheme: 'myapp',
ios: {
associatedDomains: ['applinks:example.com'],
},
android: {
intentFilters: [
{
action: 'VIEW',
autoVerify: true,
data: [{ scheme: 'https', host: 'example.com', pathPrefix: '/app' }],
category: ['BROWSABLE', 'DEFAULT'],
},
],
},
};
Performance Optimization
Optimized List Rendering
import { FlashList } from '@shopify/flash-list';
<FlashList
data={items}
renderItem={({ item }) => <ItemCard item={item} />}
estimatedItemSize={80}
keyExtractor={(item) => item.id}
/>
Image Optimization
import { Image } from 'expo-image';
<Image
source={{ uri: imageUrl }}
placeholder={{ blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.' }}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
Bundle Size Analysis
# Analyze bundle with expo-atlas
EXPO_ATLAS=1 npx expo export --platform ios
npx expo-atlas path/to/atlas-file
Migration from Bare RN to Expo
# 1. Install expo in existing project
npx install-expo-modules@latest
# 2. Create app.json
npx expo config --type public
# 3. Install Expo-compatible libraries
npx expo install react-native-reanimated react-native-gesture-handler
# 4. Enable prebuild (generates native projects from config)
npx expo prebuild --clean
# 5. Use EAS Build instead of manual native builds
eas build --profile development --platform all
Testing
Unit and Component Testing
// Jest + React Native Testing Library
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginScreen } from '../screens/LoginScreen';
describe('LoginScreen', () => {
it('shows error for invalid email', async () => {
const { getByPlaceholderText, getByText } = render(<LoginScreen />);
fireEvent.changeText(getByPlaceholderText('Email'), 'invalid');
fireEvent.press(getByText('Login'));
await waitFor(() => {
expect(getByText('Invalid email address')).toBeTruthy();
});
});
});
E2E Testing with Detox
// e2e/login.test.ts
describe('Login Flow', () => {
beforeAll(async () => { await device.launchApp(); });
beforeEach(async () => { await device.reloadReactNative(); });
it('should login successfully', async () => {
await element(by.id('email-input')).typeText('[email protected]');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
});
});
Config Plugins
Modify native project files without ejecting from the managed workflow.
// plugins/withCustomSplash.ts
import { ConfigPlugin, withAndroidStyles } from 'expo/config-plugins';
const withCustomSplash: ConfigPlugin = (config) => {
return withAndroidStyles(config, (modConfig) => {
const styles = modConfig.modResults;
// Modify Android styles.xml
return modConfig;
});
};
export default withCustomSplash;
// app.config.ts
export default {
plugins: [
'./plugins/withCustomSplash',
['expo-build-properties', {
ios: { deploymentTarget: '15.0', flipper: false },
android: { compileSdkVersion: 35, targetSdkVersion: 35, minSdkVersion: 24 },
}],
],
};
App Store and Play Store Submission with EAS
Pre-Submission Checklist
- Version bump: Update
versionin app.config.ts - Build:
eas build --profile production --platform all - Test: Download and test the production build
- Submit:
eas submit --platform all --latest
iOS-Specific Requirements
// eas.json submit config
{
"submit": {
"production": {
"ios": {
"appleId": "[email protected]",
"ascAppId": "1234567890",
"appleTeamId": "ABCDE12345"
}
}
}
}
Android-Specific Requirements
# Generate upload key (first time only)
eas credentials --platform android
# Submit with service account
eas submit --platform android --latest
Related Skills
appstore- Recommended: App Store Connect automation viaascCLI (TestFlight, submissions, metadata, signing)react-native-expert- Architecture decisions and patternsreact-native-expert- Bare RN setup, Metro, debuggingmobile-testing- Comprehensive testing strategiesdeep-linking-push- Deep linking and push notification details
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?