Every screen of an Expo Router app starts the same way: a script writes one feature folder
(src/features/<feature>/) with a pure view, its model hook, a test for each, a thin route file and
its route test, the message keys in every catalog, and, for a dynamic route, a valibot params parser
with an invalid-link state; it registers the route and the screen in the app's contracts. A second
script proves the feature is complete, on iOS and Android alike, before anything grows on it.
Rules that must hold
- Generate the feature with
scaffold-feature.mjs; never copy a neighbour. The script fills every placeholder in paths and contents, writes all files of the unit together, and never overwrites a file that differs (conflict). Why: a copied feature keeps the old testIDs, keys, titles and wiring in corners no test reads, and a hand-copied template leaves__FEATURE__behind. (placeholder-left) - One feature is one kebab-case folder, and that name is everything's name.
note-detailis the folder, the testID scope (note-detail.screen), the message-key prefix (note-detail.title), the screen-contract id and the file prefix; the types areNoteDetail…. Why: one grep finds the whole feature, and E2E flows, translators and reviewers see the same word. (feature-names) - Four files exist from the first commit, named after the folder:
<f>-screen.tsxexporting<F>Screen,use-<f>-model.tsexportinguse<F>Modelandtype <F>Model, and<f>-screen.test.tsxanduse-<f>-model.test.tsxbeside them. Why: the view is tested with plain values and the hook with providers and fakes; a screen without both tests is the one that ships broken. (feature-files,feature-names) - The route is two lines of work.
src/app/<route id>.tsxdefault-exports<F>Route, callsuse<F>Model()once, hands the translated title to the header with<Stack.Screen options>and renders<F>Screen model={model}; nothing else. Why: Expo Router bundles every file insrc/app, and logic there can be neither reused nor tested alone (architecture-and-boundaries'route-thin). (route-wiring) - Every route has its route test in the feature.
<f>-navigation.test.tsximports the route's default export from@/app/<route id>and opens it at its URL withrenderRouterWithProviders; only a route an E2E flow covers may carry// device-only:instead. Why: tests cannot live insrc/app, and only this test proves URL → route → model → view runs once as on a device. (route-test) - The view is pure and the model hook is the only reader. The screen and every
components/file import no router, store, service,src/data,src/apiort(), and call none of their hooks; the model hook reads them and hands texts translated and handlers ready. The view's root is react-components-and-hooks'<ScreenFrame edges={BELOW_HEADER_EDGES}>when the app hassrc/ui/screen-frame.tsx(the generator writes it), a plainViewuntil then. Why: the view then renders every state from plain values, the reads live in one tested place, and the Android 15+ edge-to-edge and iPhone insets are handled once. (view-pure) - Components live in
components/, a dialog with its own model in its own subfolder. The feature root holds the screen, the model hook (and its split parts), pure logic and the tests. Why: the reviewer finds the screen at a glance, and a component that a second feature needs moves tosrc/uiwithout untangling. (component-placement) - A dynamic route parses its params at the edge, in the model hook.
<f>-params.tsis a valibot schema whose keys equal the route's[param]names, returns aResult, and has its test; the model hook callsparse<F>Params(useLocalSearchParams()); the route and the view never read params. A failed parse is the invalid-link state, whose one way out isrouter.dismissTo('/'). Why: any link opens the route with any text, repeated keys arrive as arrays, and the type argument ofuseLocalSearchParams<T>()is a cast. (params-parser,params-in-model) - Every text is a key under
<f>.in every catalog, in the same change.--titleand--bodyare source-language text (the app'sSOURCE_LANGUAGE): the source catalog gets them, every other catalog gets them as drafts. The fixed texts come from the skill's translation for a language, else English as a draft. The generator lists the drafts per language; each drafted language belongs incontracts/i18n.jsonreviewLanguages, the translation queue. Why: a missing key fails a person on a phone in another language, not a test, and a draft nobody queues ships as English. (message-key-missing; after a rename,--orphans:message-key-orphan) - The feature is registered where the app keeps its contracts. Its route id is in
contracts/navigation.jsonroutes(a dynamic route also inparamParsers), andcontracts/screens.jsonhas its entry (route, model, view, states, elements). Why: a route change starts in the contract, and the screen's ids and keys are agreed there before code grows. (nav-contract,screen-contract) - The scaffold is a skeleton; everything after it is test-first. Reading data, a handler or a
state starts as a failing test in the matching test file; a model past about 40 lines splits into
use-<f>-actions.tsand a pure<f>-model-of.ts. Why: the scaffold proves the wiring once; only tests keep it true while the screen grows. - Prove the feature complete before handing it over.
check-feature.mjspasses for it, and so do the neighbours that own the deeper rules (navigation, screen contract, catalogs). Why: a feature "done" by eye is the one missing its route test or its Arabic keys.
Workflow
- Name it. Pick the feature name, route id and title in the source language
(references/feature-anatomy.md "Naming the feature",
references/routes-and-params.md "Route ids and hrefs"). A list
and its detail are two features (
notes,note-detail). Confirm the prerequisites listed in feature-anatomy "What the scaffold needs" exist; each names the skill that creates it.- An app fresh from expo-project-bootstrap still has its starter screen (
src/app/index.tsxrenderingsrc/features/home/, whose header says this skill replaces it). It holds no work: delete both, and generate the first feature at its route (--route index, or"(app)/index"in an app with an(app)group). The generator names the starter instead of overwriting it (starter), andcheck-feature.mjsreports it as onestarter-screenline until it is gone.
- An app fresh from expo-project-bootstrap still has its starter screen (
- Plan. From the repo root run
node ${CLAUDE_SKILL_DIR}/scripts/scaffold-feature.mjs . --feature <name> --route "<route id>" --title "<Title>". Read the plan:new,same,editlines, thedrafts <lang>:lines and thenotelines (the view's frame, a drafted language missing fromreviewLanguages, a Tabs parent, ahomeother than/); aconflictmeans the name, the route or its URL is taken (another route file already serves it, perhaps in another group: pick another, or finish the existing feature by hand), or a contract holds//comments the edit would drop. Compare with examples/scaffold-plan.txt. - Write. Rerun with
--write. It formats the written files with the app's Prettier when installed. The generator and its options: references/generator-and-checker.md. - Run the feature's tests and gates:
npx jest src/features/<name> --ci --maxWorkers=2, thennpm run typecheckandnpx eslint src/features/<name> src/app --max-warnings 0. - Check it:
node ${CLAUDE_SKILL_DIR}/scripts/check-feature.mjs . --feature <name>. Fix everyFAILline (file, rule, fix; each rule is explained in generator-and-checker "check-feature rules") and rerun untilRESULT: PASS. - Finish what a person decides: translate the draft keys the plan listed (add each drafted
language to
contracts/i18n.jsonreviewLanguageswhen the plan says it is missing; then i18n-strings-and-catalogs' review queue carries them), adjust the screen entry's states and elements as the design says (screen-contracts), and pick the header and presentation (navigation-and-routing). - Grow it test-first with references/test-recipes.md: a failing test, then the code, for each behaviour (data through a repository or query, handlers, the states the screen contract lists). Imitate examples/list-detail/ for a detail screen that reads data by a numeric id.
- Run the neighbours' checkers: navigation-and-routing's
check-navigation.mjs, screen-contracts'check-screens.mjs . --screen <name>, i18n-strings-and-catalogs'check-catalogs.mjs. - Rerun
check-feature.mjs(add--orphansafter renaming or deleting part of a feature) untilRESULT: PASS, and report: the route, the keys still to translate, the states still to build.
Definition of done
- The feature was written by
scaffold-feature.mjs(no copied folder), and its folder name is its testID scope, key prefix and screen id. -
<f>-screen.tsx,use-<f>-model.ts, both tests and<f>-navigation.test.tsxexist; the route calls the model hook once and renders the view. - A dynamic route has
<f>-params.tswith its test, read only by the model hook, and an invalid-link state with one way out. - The view and
components/read nothing; every text arrives translated from the model. - The view's root is
<ScreenFrame testID edges={BELOW_HEADER_EDGES}>when the app hassrc/ui/screen-frame.tsx(a plainViewonly while it does not). - No starter screen of expo-project-bootstrap is left (
src/app/index.tsx+src/features/home/). - Every key is in every catalog; every language the plan listed drafts for is in
contracts/i18n.jsonreviewLanguages(or, before that contract exists, the drafts are in the report). - The route is in
contracts/navigation.json(andparamParsers), the screen incontracts/screens.json. -
npx jest src/features/<f>,npm run typecheckand ESLint pass. - The only SKIP lines allowed are, while the app has none:
SKIP contracts/navigation.json [nav-contract] not yet created (navigation-and-routing creates it),SKIP contracts/screens.json [screen-contract] not yet created (screen-contracts creates it),SKIP src/i18n/catalogs [message-key-missing] not yet created (i18n-strings-and-catalogs creates it), or for an empty catalogs folderSKIP src/i18n/catalogs [message-key-missing] holds no catalog (i18n-strings-and-catalogs creates them). -
node ${CLAUDE_SKILL_DIR}/scripts/check-feature.mjs . --feature <f>printsRESULT: PASS
Anti-patterns
cp -R src/features/tasks src/features/notesand search-and-replace. Old ids, keys and titles survive; run the generator.- Copying the templates by hand.
__FEATURE__ends up in a path or a key; the generator fills them andplaceholder-leftcatches what is left. - Logic in the route file (
useState, a query,useLocalSearchParams). Move it to the model hook. - Calling
useT()oruseRouter()in the view "just for one label". Translate and build the handler in the model; the view stays a function of its props. - A route test under
src/app/. Expo Router bundles it as a route; it lives in the feature. - Trusting
useLocalSearchParams<{ noteId: string }>(). A cast; parse with the feature's parser. - A parser key that differs from the bracket (
[noteId].tsx, schemaid). Every link then opens the invalid-link state;params-parsernames it. - Going home with
router.navigate('/')from the invalid-link state. It stacks a second home;dismissTo('/'). - Adding keys to
en.jsononly. The other languages fail on the phone; every catalog gets them. - Overwriting the bootstrap starter by hand-editing
home-screen.tsxinto a feature. It has no model, tests or contract entry; delete it and generate the first feature at--route index. - One feature folder for a list and its detail. Two screens, two features, two models.
- Writing the data reads into the scaffold before a test asks for them. Grow it test-first.
- Calling the scaffold done because the generator passed. The feature is done at
check-feature.mjsand the neighbours' checks.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/feature-anatomy.md | The unit: naming, each file's job and imports, the model's shape and splits, components and dialogs, iOS and Android behaviour, prerequisites, pitfalls | Workflow steps 1 and 7 |
| references/routes-and-params.md | Route ids, hrefs, the header title, dynamic params and their parser, the invalid-link state, contracts, verified router facts | Workflow steps 1 and 7 |
| references/generator-and-checker.md | scaffold-feature.mjs options and plan lines; every check-feature.mjs rule with its fix; config keys; multi-package |
Workflow steps 2, 3, 5 and 9 |
| references/test-recipes.md | The view, model, route and params tests, growing test-first, Jest commands | Workflow steps 4 and 7 |
templates/src/app/__ROUTE_ID__.tsx |
The thin route of a static screen | The generator writes it |
templates/src/app/__DETAIL_ROUTE_ID__.tsx |
The thin route of a dynamic screen | The generator writes it |
templates/src/features/__FEATURE__/ |
Static screen: view, model hook, view test, model test, route test | The generator writes it |
templates/src/features/__DETAIL__/ |
Dynamic screen: the same plus the params parser and its test and the invalid-link component | The generator writes it |
templates/src/i18n/catalogs/ |
The scaffold's keys in en and ar, merged into every catalog |
The generator merges them |
| examples/scaffold-plan.txt | A dry run and a write for a dynamic route in the reference app | Workflow step 2 |
| examples/list-detail/ | A grown detail feature: numeric id parser, pure state chooser, model reading a repository | Workflow step 7 |
scripts/scaffold-feature.mjs |
Generator: plan (default) or --write; never overwrites |
Workflow steps 2 and 3 |
scripts/check-feature.mjs |
Checker: every rule above, one feature or all | Workflow steps 5 and 9 |
scripts/lib/json-format.mjs |
Prints JSON in Prettier's layout for the catalog and contract edits | When changing the generator |
scripts/lib/feature-app.mjs |
App facts both scripts read: the bootstrap starter screen and the source language | When changing either script |
scripts/selftest.mjs |
Proves both scripts: planted bugs, plans, conflicts, and the generator's output passing the checker | After changing a script, template or fixture |
scripts/check-lib.mjs |
Shared script library (synced; do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared import-resolution helpers (synced; do not edit here) | Never by hand |
assets/shared.json |
Declares the synced files | When adding a shared file |
assets/template-placeholders.json |
The placeholder values that prove the templates in the reference app | When adding a placeholder |
tests/bases/ |
Fixture apps: app/ (contracts, planted skill decoys), empty-app/ (for the generator) |
When adding a case |
tests/fixtures/ |
The self-test cases per script | When adding a rule |
Related skills
screen-contracts- what the screen shows: states, testIDs, copy keys, the view test per state.navigation-and-routing- the route contract, layouts, guards, modals and the not-found route.architecture-and-boundaries- where files live and the thin-route rule.react-components-and-hooks- the model hook, effects,ScreenFrameedges, components.i18n-strings-and-catalogs- catalogs, key names, translating the drafts.naming-conventions- file, export, testID and key shapes.expo-project-bootstrap- the new app whose starter screen the first feature replaces.unit-and-component-tests- the render helpers and Jest projects.ui-states-and-feedback- loading, empty, error and offline states as the screen grows.forms-and-input- a screen that edits data.state-management-zustandandnetworking-and-server-state- what the model hook reads.deep-links-and-universal-links- links from outside the app into the new route.e2e-maestro- the device flow for the new screen.