
Claude Code is good at TypeScript and React. Out of the box, it doesn't know your Expo app: that you route with Expo Router, style with NativeWind, or that npm install react-native-reanimated is the wrong way to add a native dependency. Every session starts with a fresh context, so anything not written down is gone.
The fix isn't a better prompt. It's a few config files checked into git that every session (and every teammate) picks up. Here's the setup I'd use for an Expo + TypeScript project.
Here's the layout we're building:
.
├── CLAUDE.md
├── .claude/
│ ├── settings.json
│ ├── hooks/
│ │ └── check-ts.sh
│ ├── rules/
│ │ └── supabase.md
│ ├── commands/
│ │ └── check.md
│ └── skills/
│ └── add-screen/
│ └── SKILL.md
└── app/
1. CLAUDE.md: the facts every session needs
CLAUDE.md at the project root (or .claude/CLAUDE.md) loads at the start of every session. You can run /init to generate a first draft from your codebase, then edit it. The memory docs recommend keeping each file under 200 lines, because longer files use more context and reduce adherence. Treat it as a list of facts and rules, not a wiki.
# Project: Expo app
## Stack
- Expo SDK 54, React Native, TypeScript (strict)
- Expo Router (file-based routes in app/)
- NativeWind for styling (Tailwind classes via className)
- Supabase for auth and data
## Commands
- Start dev server: `npx expo start`
- Typecheck: `npx tsc --noEmit`
- Lint: `npx expo lint`
- Check dependency versions: `npx expo install --check`
## Conventions
- Every file in app/ is a route. Components, hooks and utils live in
components/, hooks/ and lib/. Never put non-route files in app/.
- Layouts go in _layout.tsx. Tab screens live in app/(tabs)/.
- Style with NativeWind className. No StyleSheet.create in new code
unless a prop can't be expressed as a class.
- Use `Link` and `router` from expo-router. Do not add React Navigation
imports directly.
## Do
- Add packages with `npx expo install <pkg>`, never npm/yarn/pnpm add.
It picks versions compatible with the installed SDK.
- Run `npx tsc --noEmit` before saying a task is done.
## Don't
- Never put secrets in EXPO_PUBLIC_ variables. They are inlined into
the app bundle in plain text. Secret keys belong in server code.
- Don't edit ios/ or android/ by hand. Change app config and plugins.
- Don't create new top-level folders without asking.
A few notes on why each line is there:
- Commands are the highest-value section. Claude runs the commands it knows and guesses the rest.
- The
app/rule matters because Expo Router treats any file in the routes directory as a route. A strayhelpers.tsinapp/becomes a broken screen. (Newer Expo docs usesrc/app/. Write whichever your project actually uses.) - The
EXPO_PUBLIC_rule comes straight from the Expo environment variables guide: those values are replaced inline in your bundle, and Expo says plainly not to store sensitive info in them.
If your repo already has an AGENTS.md for other tools, you can put @AGENTS.md at the top of CLAUDE.md to import it and keep one source of truth. Claude-specific notes go below the import.
Path-scoped rules for the backend
Some rules only matter for one part of the codebase. Put those in .claude/rules/ with a paths field so they only load when Claude touches matching files:
---
paths:
- "supabase/**/*"
---
# Supabase rules
- Every new table gets RLS enabled in the same migration.
- New migrations go in supabase/migrations/. Never edit an applied one.
- Service role keys are only read inside Edge Functions.
2. Permissions: allow the boring stuff, gate the dangerous stuff
By default Claude asks before running most shell commands. That's safe, but approving npx tsc --noEmit forty times a day trains you to click "yes" without reading. Put the routine commands in an allowlist and the risky ones behind ask or deny in .claude/settings.json:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(npx expo install *)",
"Bash(npx expo lint *)",
"Bash(npx expo start *)",
"Bash(npx tsc --noEmit *)",
"Bash(npx eslint *)",
"Bash(git status)",
"Bash(git diff *)"
],
"ask": [
"Bash(git push *)",
"Bash(npx expo prebuild *)",
"Bash(eas *)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(git reset --hard *)",
"Bash(npx supabase db reset *)",
"Read(./.env)",
"Read(./.env.*)"
]
}
}
Things worth knowing about how these rules behave:
- Order is deny, then ask, then allow. A matching deny always wins, and an allow rule can't carve an exception out of a deny.
- A trailing
*matches the bare command too.Bash(npx tsc --noEmit *)coversnpx tsc --noEmitandnpx tsc --noEmit --pretty false. - Compound commands are split. An allow for
npx eslint *does not approvenpx eslint . && rm -rf build; each part has to match on its own. - I avoid a blanket
Bash(npx expo *).npxisn't one of the wrappers Claude Code strips before matching, so that rule approves every Expo subcommand, includingprebuild --clean, which regenerates your native folders. Listing subcommands is more typing and much clearer. - Bash deny rules are a guardrail, not a sandbox. The permissions docs note that a deny like
Bash(rm *)won't match/bin/rmorbash -c '...'. Use them to stop accidents, not to contain an adversary.
Commit settings.json; personal tweaks go in .claude/settings.local.json.
3. Hooks: make typecheck and lint automatic
CLAUDE.md is advice. Claude treats it as context, and it can still skip a step. Hooks are deterministic: Claude Code runs them itself at fixed points. A PostToolUse hook with an Edit|Write matcher runs after every file edit, which is exactly where you want lint and typecheck.
Add this to the same settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-ts.sh"
}
]
}
]
}
}
And the script, .claude/hooks/check-ts.sh:
#!/usr/bin/env bash
# Runs after Edit/Write. Hook input arrives as JSON on stdin.
FILE=$(jq -r '.tool_input.file_path // empty')
case "$FILE" in
*.ts|*.tsx) ;;
*) exit 0 ;;
esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
errors=""
if ! out=$(npx eslint "$FILE" 2>&1); then
errors+="ESLint ($FILE):"$'\n'"$out"$'\n'
fi
if ! out=$(npx tsc --noEmit --pretty false 2>&1); then
errors+="TypeScript:"$'\n'"$out"$'\n'
fi
if [ -n "$errors" ]; then
echo "$errors" >&2
exit 2
fi
exit 0
Make it executable with chmod +x .claude/hooks/check-ts.sh. You'll need jq installed.
The important part is exit 2. For PostToolUse, the edit has already happened, so nothing gets blocked. Instead, Claude Code shows stderr to Claude, which then sees the type errors and fixes them in the next step. Any other non-zero exit is treated as a non-blocking error and doesn't feed the output back the same way. The hooks guide has more patterns, including a Prettier-on-save example built the same way. Run /hooks in a session to confirm yours is registered.
Trade-off: a full tsc --noEmit per edit gets slow in big projects. If it hurts, move the typecheck to a Stop hook and keep the single-file ESLint run here.
4. Slash commands and skills
Custom commands have been merged into skills. A file at .claude/commands/check.md and a skill at .claude/skills/check/SKILL.md both create /check. Old command files keep working; skills add a folder for supporting files and control over when Claude loads them.
A small command is fine as a single file. .claude/commands/check.md:
---
description: Typecheck, lint and verify Expo dependency versions
allowed-tools: Bash(npx tsc --noEmit *) Bash(npx expo lint *) Bash(npx expo install --check *)
---
Run `npx tsc --noEmit`, `npx expo lint` and `npx expo install --check`.
Summarize failures grouped by file. Do not fix anything yet.
For a multi-step workflow, use a skill. .claude/skills/add-screen/SKILL.md:
---
name: add-screen
description: Add a new Expo Router screen with NativeWind styling and navigation wired up
argument-hint: [route-path] [what the screen does]
disable-model-invocation: true
allowed-tools: Bash(npx tsc --noEmit *) Bash(npx expo lint *)
---
Add a screen at app/$0.tsx. Purpose: $ARGUMENTS
1. Read app/_layout.tsx and the nearest existing screen to match patterns.
2. If the route has a dynamic segment like [id], read it with
useLocalSearchParams and type the params.
3. Put reusable UI in components/, not in app/.
4. Style with NativeWind className only.
5. Add a Link or router.push from the screen that should open it.
6. Run `npx tsc --noEmit` and `npx expo lint`, fix anything you introduced.
7. Reply with the files changed and how to reach the screen in the app.
What the frontmatter does:
disable-model-invocation: truemeans only you can trigger it with/add-screen. Leave it off for skills you want Claude to pick up automatically when thedescriptionmatches the task.allowed-toolspre-approves those commands while the skill is running.$ARGUMENTSexpands to everything after the command name;$0,$1are positional./add-screen settings/notifications "toggle push and email alerts"puts the path in$0.
5. Adding a screen end to end
With this in place, a real session looks like:
/add-screen orders/[id] "order detail with status, items and a cancel button"
Claude reads your layout and an existing screen, creates app/orders/[id].tsx, pulls a list row into components/, wires a Link from the orders list, and runs the checks. A type error gets pushed back by the hook before it can call the task done. If the change touches supabase/, the rule file loads and the RLS rule is in context without you repeating it.
For larger changes (a new tab, an auth flow), ask for a plan first and read it before any files change. Plan mode is built for this, and it is the cheapest point to catch "I'll add React Navigation" before it happens.
Reviewing what the agent wrote
Green checks mean the code compiles, not that the screen works. My review pass:
- Read the diff.
git diffthe whole change. Look for files outside the scope, new dependencies inpackage.json, and anything added toapp/that isn't a route. - Check dependencies.
npx expo install --checkflags packages that don't match your SDK. - Grep for leaked config. Any new
EXPO_PUBLIC_variable should be something you'd be fine printing on a billboard. - Run it on a device.
npx expo start, open it on a real phone, and tap through: back navigation, keyboard, safe areas. A typecheck won't catch a button hidden under the home indicator.
When the agent gets something wrong twice, add a line to CLAUDE.md or a rule file.
Starting from a pre-configured template
This is a one-time setup, but it takes time to tune. I work on AppLighter, which sells Expo SDK 54 templates (TypeScript, Expo Router, NativeWind, Supabase with RLS on every table) that ship with CLAUDE.md, AGENTS.md and .cursorrules already written, plus skills and commands like /add-screen, /swap-backend, /audit-security and /ship. If you'd rather build your own, the files above are a solid base.
Wrapping up
Facts in CLAUDE.md, scoped rules in .claude/rules/, workflows in skills, guarantees in hooks, boundaries in permissions. Each layer covers what the previous one can't enforce. If you only do two things, write the commands section of CLAUDE.md and add the typecheck hook.
What's the one rule you've had to add to your own CLAUDE.md because the agent kept getting it wrong in a React Native project?
Comments
Join the discussionThe npx expo install versus npm install point deserves to be near the top, since a mismatched reanimated or gesture handler version is the most common way an Expo app breaks after a dependency change. Adding a rule to run npx expo-doctor after any package change catches those mismatches before the next build. It is also worth listing which packages need a dev client rebuild, so nobody wastes time debugging Expo Go.

