
Expo Architect
- 260 installs
- 31 repo stars
- Updated August 2, 2026
- shipshitdev/library
expo-architect is an agent skill that scaffolds production-ready Expo React Native apps with Expo Router navigation, TypeScript, and optional Clerk auth so developers starting mobile projects get runnable screens from bu
About
expo-architect is a shipshitdev/library agent skill that generates complete Expo React Native project structures instead of empty templates. It targets Expo SDK 54 with React Native 0.83, TypeScript strict mode, Expo Router file-based navigation, Bun package management, Biome linting, and optional Clerk authentication with sign-in and sign-up flows. Developers reach for expo-architect at greenfield mobile starts when they need tab or stack navigation, entity components, API client wiring, and environment variable hints for Clerk and backend URLs. The skill follows a six-step workflow: PRD intake, optional auth setup, screen generation, component generation, quality tooling configuration, and verification gates so bun start runs a working app immediately. UI can use NativeWind Tailwind classes or classic StyleSheet patterns. Output includes layouts, providers, types, and path aliases ready for feature development rather than days of navigation and auth boilerplate.
- Expo Router and navigation patterns
- Native module and config plugin planning
- Monorepo and EAS build layout
- Cross-platform UI component boundaries
- Environment and release channel strategy
Expo Architect by the numbers
- 260 all-time installs (skills.sh)
- +4 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #389 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shipshitdev/library --skill expo-architectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 260 |
|---|---|
| repo stars | ★ 31 |
| Last updated | August 2, 2026 |
| Repository | shipshitdev/library ↗ |
How do you scaffold an Expo React Native app with navigation?
Architect Expo/React Native apps: navigation, folder layout, native modules, EAS builds, and cross-platform UI patterns before writing feature code.
Who is it for?
Developers starting greenfield Expo React Native apps who want file-based routing, strict TypeScript, and optional Clerk auth without manual boilerplate.
Skip if: Brownfield React Native migrations, bare React Native without Expo, or backend-only API projects without a mobile client.
When should I use this skill?
The user scaffolds a new Expo app, needs Expo Router navigation, Clerk mobile auth, or a runnable React Native project from a PRD brief.
What you get
Runnable Expo project tree with Expo Router screens, providers, API client, TypeScript types, and optional Clerk authentication flows.
- Expo Router project directory
- Screen and component files
- Optional Clerk auth flow
By the numbers
- Follows a 6-step workflow from PRD intake through verification
- Targets Expo SDK 54 with React Native 0.83 and TypeScript strict mode
Files
Expo Architect
Create production-ready Expo React Native apps with:
- Framework: Expo SDK 54 + React Native 0.83 + TypeScript
- Navigation: Expo Router (file-based routing)
- Auth: Clerk authentication (optional)
- UI: NativeWind (Tailwind for RN) or StyleSheet
- Quality: Biome linting + TypeScript strict mode
- Package Manager: bun
What Makes This Different
Generates working mobile apps, not empty scaffolds:
- Complete navigation structure with working screens
- Optional Clerk authentication flow
- Real UI components with proper styling
- API client integration ready
- Runs immediately with
bun start
Workflow Summary
1. PRD Brief Intake - Extract app type, screens, features, auth needs 2. Auth Setup (if requested) - Clerk provider, sign-in/sign-up screens 3. Screen Generation - Tab or stack-based navigation 4. Component Generation - UI components, entity components, layouts 5. Quality Setup - Biome, TypeScript strict, path aliases 6. Verification - Run quality gate, report results
Usage
# Create app with PRD-style prompt
python3 scripts/init-expo.py \
--root ~/www/myapp \
--name "My App" \
--brief "A fitness tracker where users can log workouts"
# With specific options
python3 scripts/init-expo.py \
--root ~/www/myapp \
--name "My App" \
--tabs "Home,Workouts,Profile" \
--authGenerated Structure
myapp/
├── app/
│ ├── _layout.tsx # Root layout
│ ├── (tabs)/ # Tab navigator
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ └── ...
│ └── (auth)/ # Auth screens (if enabled)
├── components/
│ ├── ui/ # Base UI components
│ ├── [entity]/ # Feature components
│ └── layout/ # Layout components
├── lib/
│ ├── api.ts # API client
│ └── auth.ts # Auth utilities
├── providers/ # Context providers
├── types/ # TypeScript types
├── app.json # Expo config
├── package.json
├── tsconfig.json
└── biome.jsonDevelopment Commands
bun start # Start Expo dev server
bun run ios # iOS simulator
bun run android # Android emulator
bun run lint # Check code style
bun run typecheck # Type checkingEnvironment Variables
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
EXPO_PUBLIC_API_URL=http://localhost:3001---
For detailed patterns, code templates, and complete examples: references/full-guide.md
{
"name": "expo-architect",
"version": "1.0.0",
"description": "Scaffold a production-ready Expo React Native app with working screens, navigation, and optional Cle",
"author": {
"name": "Ship Shit Dev",
"email": "hello@shipshit.dev",
"url": "https://shipshit.dev"
},
"license": "MIT",
"skills": "."
}
Expo Architect - Full Guide
Complete guide to scaffolding production-ready Expo React Native apps.
Phase 1: PRD Brief Intake
Ask the user for a brief product description, then extract and confirm:
I'll help you build [App Name]. Based on your description, I understand:
**App Type:** [Tab-based / Stack-based / Drawer]
**Screens:**
- Home - [description]
- [Screen2] - [description]
- [Screen3] - [description]
**Features:**
- [Feature 1]
- [Feature 2]
**Auth Required:** Yes/No
**Backend API:** [URL or "none"]
Is this correct? Any adjustments?Phase 2: Auth Setup (If Requested)
Generate Clerk authentication for mobile:
Files:
providers/clerk-provider.tsx- ClerkProvider wrapperapp/(auth)/sign-in.tsx- Sign in screenapp/(auth)/sign-up.tsx- Sign up screenlib/auth.ts- Auth utilities
Environment:
.envwithEXPO_PUBLIC_CLERK_PUBLISHABLE_KEY
Phase 3: Screen Generation
Tab-based App Structure
app/
├── _layout.tsx # Root layout with providers
├── (tabs)/
│ ├── _layout.tsx # Tab navigator
│ ├── index.tsx # Home tab
│ ├── [screen].tsx # Other tabs
│ └── ...
├── (auth)/ # Auth screens (if enabled)
│ ├── sign-in.tsx
│ └── sign-up.tsx
└── [entity]/
└── [id].tsx # Detail screensStack-based App Structure
app/
├── _layout.tsx # Root layout with providers
├── index.tsx # Home screen
├── [screen].tsx # Other screens
└── (auth)/ # Auth screens (if enabled)Phase 4: Component Generation
components/
├── ui/
│ ├── Button.tsx
│ ├── Input.tsx
│ ├── Card.tsx
│ └── Text.tsx
├── [entity]/
│ ├── [Entity]List.tsx
│ ├── [Entity]Item.tsx
│ └── [Entity]Form.tsx
└── layout/
├── Header.tsx
└── SafeContainer.tsxKey Patterns
Root Layout Pattern
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { ClerkProvider } from "@/providers/clerk-provider";
import { QueryProvider } from "@/providers/query-provider";
export default function RootLayout() {
return (
<ClerkProvider>
<QueryProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</QueryProvider>
</ClerkProvider>
);
}Tab Navigator Pattern
import { Tabs } from "expo-router";
import { Home, Dumbbell, User } from "lucide-react-native";
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#3b82f6",
tabBarInactiveTintColor: "#9ca3af",
tabBarStyle: { backgroundColor: "#0f172a" },
headerStyle: { backgroundColor: "#0f172a" },
headerTintColor: "#fff",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => <Home size={size} color={color} />,
}}
/>
<Tabs.Screen
name="workouts"
options={{
title: "Workouts",
tabBarIcon: ({ color, size }) => <Dumbbell size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: "Profile",
tabBarIcon: ({ color, size }) => <User size={size} color={color} />,
}}
/>
</Tabs>
);
}Screen Component Pattern
import { View, Text, FlatList, StyleSheet } from "react-native";
import { useEffect, useState } from "react";
import { SafeContainer } from "@/components/layout/SafeContainer";
import { WorkoutItem } from "@/components/workout/WorkoutItem";
import { api } from "@/lib/api";
import type { Workout } from "@/types";
export default function WorkoutsScreen() {
const [workouts, setWorkouts] = useState<Workout[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.workouts.getAll()
.then(setWorkouts)
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<SafeContainer style={styles.center}>
<Text>Loading...</Text>
</SafeContainer>
);
}
return (
<SafeContainer>
<FlatList
data={workouts}
keyExtractor={(item) => item._id}
renderItem={({ item }) => <WorkoutItem workout={item} />}
contentContainerStyle={styles.list}
/>
</SafeContainer>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: "center", justifyContent: "center" },
list: { padding: 16, gap: 12 },
});API Client Pattern
const API_URL = process.env.EXPO_PUBLIC_API_URL || "http://localhost:3001";
async function getAuthToken(): Promise<string | null> {
// Get token from Clerk
const clerk = (global as any).Clerk;
if (!clerk?.session) return null;
return clerk.session.getToken();
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = await getAuthToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
export const api = {
workouts: {
getAll: () => request<Workout[]>("/workouts"),
getById: (id: string) => request<Workout>(`/workouts/${id}`),
create: (data: Partial<Workout>) =>
request<Workout>("/workouts", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: string, data: Partial<Workout>) =>
request<Workout>(`/workouts/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
delete: (id: string) =>
request<void>(`/workouts/${id}`, { method: "DELETE" }),
},
};EAS Build Configuration
For production builds, create eas.json:
{
"cli": { "version": ">= 5.0.0" },
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {}
},
"submit": {
"production": {}
}
}Quality Verification
Run quality gate and report results:
✅ Generation complete!
Quality Report:
- bun install: ✓ succeeded
- bun run lint: ✓ 0 errors
- TypeScript: ✓ no errors
Ready to run:
cd [project]
bun start#!/usr/bin/env python3
"""
Scaffold a production-ready Expo React Native app.
This script creates a complete Expo mobile app with:
- Expo Router for file-based navigation
- Optional Clerk authentication
- TypeScript with strict mode
- Biome linting
- Working screens and components
Usage:
python3 init-expo.py --root ~/www/myapp --name "My App"
python3 init-expo.py --root ~/www/myapp --name "Fitness" --tabs "Home,Workouts,Profile" --auth
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from textwrap import dedent
from dataclasses import dataclass
@dataclass
class ScreenConfig:
"""Configuration for a screen to be generated."""
name: str # e.g., "Home"
icon: str = "Home" # Lucide icon name
@property
def pascal_case(self) -> str:
return self.name[0].upper() + self.name[1:]
@property
def kebab_case(self) -> str:
return self.name.lower().replace(" ", "-")
@property
def file_name(self) -> str:
if self.name.lower() == "home":
return "index"
return self.kebab_case
# =============================================================================
# PACKAGE.JSON AND CONFIG TEMPLATES
# =============================================================================
def create_package_json(name: str, with_auth: bool) -> str:
slug = name.lower().replace(" ", "-")
deps = {
"expo": "~54.0.0",
"expo-router": "~6.0.0",
"expo-status-bar": "~3.0.0",
"expo-constants": "~18.0.0",
"expo-linking": "~7.0.0",
"react": "19.0.0",
"react-native": "0.83.0",
"react-native-safe-area-context": "~5.4.0",
"react-native-screens": "~4.10.0",
"lucide-react-native": "~0.470.0",
"react-native-svg": "~15.9.0",
}
if with_auth:
deps["@clerk/clerk-expo"] = "~4.0.0"
deps["expo-secure-store"] = "~15.0.0"
return json.dumps({
"name": slug,
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": deps,
"devDependencies": {
"@types/react": "~19.0.0",
"typescript": "~5.9.0",
"@biomejs/biome": "~2.3.0"
}
}, indent=2)
def create_app_json(name: str) -> str:
slug = name.lower().replace(" ", "-")
return json.dumps({
"expo": {
"name": name,
"slug": slug,
"version": "1.0.0",
"scheme": slug,
"platforms": ["ios", "android"],
"ios": {
"supportsTablet": True,
"bundleIdentifier": f"com.{slug.replace('-', '')}.app"
},
"android": {
"package": f"com.{slug.replace('-', '')}.app"
},
"experiments": {
"typedRoutes": True
}
}
}, indent=2)
def create_tsconfig() -> str:
return json.dumps({
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": True,
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"],
"@/providers/*": ["providers/*"],
"@/types/*": ["types/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}, indent=2)
def create_biome_config() -> str:
return json.dumps({
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
"assist": {
"actions": {
"source": {"organizeImports": "on"}
}
},
"files": {
"ignore": ["node_modules", ".expo", "dist"]
},
"formatter": {
"enabled": True,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": True,
"rules": {
"recommended": True
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "es5"
}
}
}, indent=2)
def create_env_example(with_auth: bool) -> str:
content = "# API\nEXPO_PUBLIC_API_URL=http://localhost:3001\n"
if with_auth:
content += "\n# Clerk Authentication\nEXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx\n"
return content
def create_gitignore() -> str:
return dedent("""\
# Dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Environment
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
""")
# =============================================================================
# ROOT LAYOUT AND PROVIDERS
# =============================================================================
def create_root_layout(with_auth: bool) -> str:
if with_auth:
return dedent("""\
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { ClerkProvider } from "@/providers/clerk-provider";
export default function RootLayout() {
return (
<ClerkProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</ClerkProvider>
);
}
""")
else:
return dedent("""\
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
return (
<>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</>
);
}
""")
def create_clerk_provider() -> str:
return dedent("""\
import { ClerkProvider as BaseClerkProvider } from "@clerk/clerk-expo";
import * as SecureStore from "expo-secure-store";
import { ReactNode } from "react";
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!;
const tokenCache = {
async getToken(key: string) {
try {
return SecureStore.getItemAsync(key);
} catch {
return null;
}
},
async saveToken(key: string, value: string) {
try {
return SecureStore.setItemAsync(key, value);
} catch {
return;
}
},
};
interface Props {
children: ReactNode;
}
export function ClerkProvider({ children }: Props) {
return (
<BaseClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
{children}
</BaseClerkProvider>
);
}
""")
# =============================================================================
# TAB NAVIGATOR
# =============================================================================
def create_tab_layout(screens: list[ScreenConfig]) -> str:
imports = ["import { Tabs } from \"expo-router\";"]
icon_imports = ", ".join(s.icon for s in screens)
imports.append(f'import {{ {icon_imports} }} from "lucide-react-native";')
tabs = []
for screen in screens:
tabs.append(f"""\
<Tabs.Screen
name="{screen.file_name}"
options={{{{
title: "{screen.pascal_case}",
tabBarIcon: ({{ color, size }}) => <{screen.icon} size={{size}} color={{color}} />,
}}}}
/>""")
tabs_str = "\n".join(tabs)
return dedent(f"""\
{chr(10).join(imports)}
export default function TabLayout() {{
return (
<Tabs
screenOptions={{{{
tabBarActiveTintColor: "#3b82f6",
tabBarInactiveTintColor: "#9ca3af",
tabBarStyle: {{ backgroundColor: "#0f172a" }},
headerStyle: {{ backgroundColor: "#0f172a" }},
headerTintColor: "#fff",
}}}}
>
{tabs_str}
</Tabs>
);
}}
""")
def create_screen_component(screen: ScreenConfig) -> str:
return dedent(f"""\
import {{ View, Text, StyleSheet }} from "react-native";
import {{ SafeContainer }} from "@/components/layout/SafeContainer";
export default function {screen.pascal_case}Screen() {{
return (
<SafeContainer>
<View style={{styles.container}}>
<Text style={{styles.title}}>{screen.pascal_case}</Text>
<Text style={{styles.subtitle}}>This is the {screen.name.lower()} screen.</Text>
</View>
</SafeContainer>
);
}}
const styles = StyleSheet.create({{
container: {{
flex: 1,
padding: 20,
}},
title: {{
fontSize: 28,
fontWeight: "bold",
color: "#fff",
marginBottom: 8,
}},
subtitle: {{
fontSize: 16,
color: "#9ca3af",
}},
}});
""")
# =============================================================================
# AUTH SCREENS
# =============================================================================
def create_auth_layout() -> str:
return dedent("""\
import { Stack } from "expo-router";
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#0f172a" },
headerTintColor: "#fff",
}}
>
<Stack.Screen name="sign-in" options={{ title: "Sign In" }} />
<Stack.Screen name="sign-up" options={{ title: "Sign Up" }} />
</Stack>
);
}
""")
def create_sign_in_screen() -> str:
return dedent("""\
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
import { useState } from "react";
import { useSignIn } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { SafeContainer } from "@/components/layout/SafeContainer";
export default function SignInScreen() {
const { signIn, setActive, isLoaded } = useSignIn();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSignIn = async () => {
if (!isLoaded) return;
try {
const result = await signIn.create({
identifier: email,
password,
});
if (result.status === "complete") {
await setActive({ session: result.createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
setError(err.errors?.[0]?.message || "Sign in failed");
}
};
return (
<SafeContainer>
<View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text>
{error && <Text style={styles.error}>{error}</Text>}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#9ca3af"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#9ca3af"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Pressable style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>Sign In</Text>
</Pressable>
<Pressable onPress={() => router.push("/(auth)/sign-up")}>
<Text style={styles.link}>Don't have an account? Sign Up</Text>
</Pressable>
</View>
</SafeContainer>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
marginBottom: 24,
textAlign: "center",
},
input: {
backgroundColor: "#1e293b",
color: "#fff",
padding: 16,
borderRadius: 8,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: "#3b82f6",
padding: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
link: {
color: "#3b82f6",
textAlign: "center",
marginTop: 16,
},
error: {
color: "#ef4444",
marginBottom: 12,
textAlign: "center",
},
});
""")
def create_sign_up_screen() -> str:
return dedent("""\
import { View, Text, TextInput, Pressable, StyleSheet } from "react-native";
import { useState } from "react";
import { useSignUp } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { SafeContainer } from "@/components/layout/SafeContainer";
export default function SignUpScreen() {
const { signUp, setActive, isLoaded } = useSignUp();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSignUp = async () => {
if (!isLoaded) return;
try {
const result = await signUp.create({
emailAddress: email,
password,
});
if (result.status === "complete") {
await setActive({ session: result.createdSessionId });
router.replace("/(tabs)");
}
} catch (err: any) {
setError(err.errors?.[0]?.message || "Sign up failed");
}
};
return (
<SafeContainer>
<View style={styles.container}>
<Text style={styles.title}>Create Account</Text>
{error && <Text style={styles.error}>{error}</Text>}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#9ca3af"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#9ca3af"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Pressable style={styles.button} onPress={handleSignUp}>
<Text style={styles.buttonText}>Sign Up</Text>
</Pressable>
<Pressable onPress={() => router.push("/(auth)/sign-in")}>
<Text style={styles.link}>Already have an account? Sign In</Text>
</Pressable>
</View>
</SafeContainer>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: "center",
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
marginBottom: 24,
textAlign: "center",
},
input: {
backgroundColor: "#1e293b",
color: "#fff",
padding: 16,
borderRadius: 8,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: "#3b82f6",
padding: 16,
borderRadius: 8,
alignItems: "center",
marginTop: 8,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
link: {
color: "#3b82f6",
textAlign: "center",
marginTop: 16,
},
error: {
color: "#ef4444",
marginBottom: 12,
textAlign: "center",
},
});
""")
# =============================================================================
# UI COMPONENTS
# =============================================================================
def create_safe_container() -> str:
return dedent("""\
import { SafeAreaView, StyleSheet, ViewStyle } from "react-native";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
style?: ViewStyle;
}
export function SafeContainer({ children, style }: Props) {
return (
<SafeAreaView style={[styles.container, style]}>
{children}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
},
});
""")
def create_button_component() -> str:
return dedent("""\
import { Pressable, Text, StyleSheet, ViewStyle } from "react-native";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
onPress: () => void;
variant?: "primary" | "secondary" | "ghost";
style?: ViewStyle;
}
export function Button({ children, onPress, variant = "primary", style }: Props) {
return (
<Pressable
style={[styles.button, styles[variant], style]}
onPress={onPress}
>
<Text style={[styles.text, variant === "ghost" && styles.ghostText]}>
{children}
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
padding: 16,
borderRadius: 8,
alignItems: "center",
},
primary: {
backgroundColor: "#3b82f6",
},
secondary: {
backgroundColor: "#1e293b",
},
ghost: {
backgroundColor: "transparent",
},
text: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
ghostText: {
color: "#3b82f6",
},
});
""")
def create_input_component() -> str:
return dedent("""\
import { TextInput, StyleSheet, TextInputProps } from "react-native";
interface Props extends TextInputProps {
// Add custom props here
}
export function Input(props: Props) {
return (
<TextInput
style={styles.input}
placeholderTextColor="#9ca3af"
{...props}
/>
);
}
const styles = StyleSheet.create({
input: {
backgroundColor: "#1e293b",
color: "#fff",
padding: 16,
borderRadius: 8,
fontSize: 16,
},
});
""")
def create_card_component() -> str:
return dedent("""\
import { View, StyleSheet, ViewStyle } from "react-native";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
style?: ViewStyle;
}
export function Card({ children, style }: Props) {
return (
<View style={[styles.card, style]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: "#1e293b",
borderRadius: 12,
padding: 16,
},
});
""")
# =============================================================================
# LIB FILES
# =============================================================================
def create_api_client() -> str:
return dedent("""\
const API_URL = process.env.EXPO_PUBLIC_API_URL || "http://localhost:3001";
async function getAuthToken(): Promise<string | null> {
const clerk = (global as any).Clerk;
if (!clerk?.session) return null;
return clerk.session.getToken();
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = await getAuthToken();
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
export const api = {
get: <T>(endpoint: string) => request<T>(endpoint),
post: <T>(endpoint: string, data: unknown) =>
request<T>(endpoint, {
method: "POST",
body: JSON.stringify(data),
}),
patch: <T>(endpoint: string, data: unknown) =>
request<T>(endpoint, {
method: "PATCH",
body: JSON.stringify(data),
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: "DELETE" }),
};
""")
def create_types_index() -> str:
return dedent("""\
// Add your TypeScript types here
export interface User {
_id: string;
email: string;
name?: string;
createdAt: string;
updatedAt: string;
}
// Example entity type
export interface Entity {
_id: string;
title: string;
description?: string;
userId: string;
createdAt: string;
updatedAt: string;
}
""")
# =============================================================================
# MAIN SCAFFOLD FUNCTION
# =============================================================================
def scaffold_expo_app(
root: Path,
name: str,
screens: list[ScreenConfig],
with_auth: bool,
allow_outside: bool,
) -> None:
"""Create the full Expo app structure."""
cwd = Path.cwd()
if not allow_outside and not root.is_relative_to(cwd):
print(f"Error: Target path {root} is outside current directory.")
print("Use --allow-outside to confirm this is intentional.")
sys.exit(1)
if root.exists():
print(f"Error: {root} already exists.")
sys.exit(1)
print(f"Creating Expo app at {root}...")
print(f"Screens: {', '.join(s.name for s in screens)}")
print(f"Auth: {'Yes' if with_auth else 'No'}")
# Create directories
dirs = [
root,
root / ".agents",
root / "app" / "(tabs)",
root / "components" / "layout",
root / "components" / "ui",
root / "lib",
root / "types",
]
if with_auth:
dirs.extend([
root / "app" / "(auth)",
root / "providers",
])
for d in dirs:
d.mkdir(parents=True, exist_ok=True)
# Base files
files = {
root / "package.json": create_package_json(name, with_auth),
root / "app.json": create_app_json(name),
root / "tsconfig.json": create_tsconfig(),
root / "biome.json": create_biome_config(),
root / ".env.example": create_env_example(with_auth),
root / ".gitignore": create_gitignore(),
# Root layout
root / "app" / "_layout.tsx": create_root_layout(with_auth),
# Tab navigator
root / "app" / "(tabs)" / "_layout.tsx": create_tab_layout(screens),
# Components
root / "components" / "layout" / "SafeContainer.tsx": create_safe_container(),
root / "components" / "ui" / "Button.tsx": create_button_component(),
root / "components" / "ui" / "Input.tsx": create_input_component(),
root / "components" / "ui" / "Card.tsx": create_card_component(),
# Lib
root / "lib" / "api.ts": create_api_client(),
# Types
root / "types" / "index.ts": create_types_index(),
}
# Screen files
for screen in screens:
files[root / "app" / "(tabs)" / f"{screen.file_name}.tsx"] = create_screen_component(screen)
# Auth files
if with_auth:
files[root / "providers" / "clerk-provider.tsx"] = create_clerk_provider()
files[root / "app" / "(auth)" / "_layout.tsx"] = create_auth_layout()
files[root / "app" / "(auth)" / "sign-in.tsx"] = create_sign_in_screen()
files[root / "app" / "(auth)" / "sign-up.tsx"] = create_sign_up_screen()
# Write all files
for filepath, content in files.items():
filepath.write_text(content)
print(f"Created: {filepath.relative_to(root)}")
print(f"\n✅ Expo app created at: {root}")
print(f"\nNext steps:")
print(f"1. cd {root}")
print(f"2. bun install")
if with_auth:
print(f"3. Copy .env.example to .env and add your Clerk key")
print(f"4. bun start")
else:
print(f"3. bun start")
def main() -> None:
parser = argparse.ArgumentParser(
description="Scaffold a production-ready Expo React Native app."
)
parser.add_argument(
"--root",
type=Path,
required=True,
help="Directory to create the app in",
)
parser.add_argument(
"--name",
type=str,
required=True,
help="App name",
)
parser.add_argument(
"--tabs",
type=str,
default="Home,Settings",
help="Comma-separated list of tab names (default: 'Home,Settings')",
)
parser.add_argument(
"--auth",
action="store_true",
help="Include Clerk authentication",
)
parser.add_argument(
"--allow-outside",
action="store_true",
help="Allow creating files outside current directory",
)
args = parser.parse_args()
# Parse screens with default icons
icon_map = {
"home": "Home",
"settings": "Settings",
"profile": "User",
"search": "Search",
"workouts": "Dumbbell",
"explore": "Compass",
"favorites": "Heart",
"notifications": "Bell",
"messages": "MessageCircle",
"calendar": "Calendar",
}
screens = []
for tab_name in args.tabs.split(","):
tab_name = tab_name.strip()
icon = icon_map.get(tab_name.lower(), "Circle")
screens.append(ScreenConfig(name=tab_name, icon=icon))
scaffold_expo_app(
root=args.root.resolve(),
name=args.name,
screens=screens,
with_auth=args.auth,
allow_outside=args.allow_outside,
)
if __name__ == "__main__":
main()
Related skills
How it compares
Choose expo-architect over generic React Native skills when you need opinionated Expo Router + Bun + Biome scaffolding with optional Clerk auth in one runnable output.
FAQ
What stack does expo-architect generate by default?
expo-architect scaffolds Expo SDK 54 with React Native 0.83, TypeScript strict mode, Expo Router navigation, Bun package management, Biome linting, and NativeWind or StyleSheet UI components.
Does expo-architect include authentication setup?
expo-architect optionally wires Clerk authentication with provider configuration, sign-in and sign-up screens, and environment variable placeholders for Clerk and API keys when auth is requested in the PRD intake.
How is expo-architect different from empty Expo templates?
expo-architect outputs working navigation layouts, entity components, API client utilities, and quality tooling so developers can run bun start immediately instead of spending initial sprints on routing and auth boilerplate.