Skip to content
Back to skills

Scaffold Feature Screen

ASecurity

Scaffolds one Expo Router feature - thin route, pure view, model hook, tests, message keys, typed params - and proves it. Use when adding a screen, page or feature folder, or checking one is done. Not for screen contents (screen-contracts).

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 18, 2026
developmentrustgoreactnodeapi

Works with

  • api

Security analysis

A100/100

Pro scans all 19 files and shows the line behind each finding

Scanned October 18, 2026

npx -y skills add zakariaf/ReactNative-Skills --skill scaffold-feature-screen --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Scaffold Feature Screen?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Scaffold Feature Screen
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-scaffold-feature-screen/badge)](https://www.skillsdirectory.com/skills/zakariaf-scaffold-feature-screen)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: scaffold-feature-screen
description: Scaffolds one Expo Router feature - thin route, pure view, model hook, tests, message keys, typed params - and proves it. Use when adding a screen, page or feature folder, or checking one is done. Not for screen contents (screen-contracts).
---

# Scaffold a feature screen

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

1. **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`)
2. **One feature is one kebab-case folder, and that name is everything's name.** `note-detail` is
   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 are `NoteDetail…`. Why: one grep finds the
   whole feature, and E2E flows, translators and reviewers see the same word. (`feature-names`)
3. **Four files exist from the first commit, named after the folder:** `<f>-screen.tsx` exporting
   `<F>Screen`, `use-<f>-model.ts` exporting `use<F>Model` and `type <F>Model`, and
   `<f>-screen.test.tsx` and `use-<f>-model.test.tsx` beside 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`)
4. **The route is two lines of work.** `src/app/<route id>.tsx` default-exports `<F>Route`, calls
   `use<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 in `src/app`,
   and logic there can be neither reused nor tested alone (architecture-and-boundaries' `route-thin`).
   (`route-wiring`)
5. **Every route has its route test in the feature.** `<f>-navigation.test.tsx` imports the route's
   default export from `@/app/<route id>` and opens it at its URL with `renderRouterWithProviders`;
   only a route an E2E flow covers may carry `// device-only:` instead. Why: tests cannot live in
   `src/app`, and only this test proves URL → route → model → view runs once as on a device.
   (`route-test`)
6. **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/api` or `t()`, 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 has
   `src/ui/screen-frame.tsx` (the generator writes it), a plain `View` until 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`)
7. **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 to
   `src/ui` without untangling. (`component-placement`)
8. **A dynamic route parses its params at the edge, in the model hook.** `<f>-params.ts` is a valibot
   schema whose keys equal the route's `[param]` names, returns a `Result`, and has its test; the
   model hook calls `parse<F>Params(useLocalSearchParams())`; the route and the view never read
   params. A failed parse is the invalid-link state, whose one way out is `router.dismissTo('/')`.
   Why: any link opens the route with any text, repeated keys arrive as arrays, and the type argument
   of `useLocalSearchParams<T>()` is a cast. (`params-parser`, `params-in-model`)
9. **Every text is a key under `<f>.` in every catalog, in the same change.** `--title` and `--body`
   are source-language text (the app's `SOURCE_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 in
   `contracts/i18n.json` `reviewLanguages`, 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`)
10. **The feature is registered where the app keeps its contracts.** Its route id is in
    `contracts/navigation.json` `routes` (a dynamic route also in `paramParsers`), and
    `contracts/screens.json` has 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`)
11. **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.ts` and a pure `<f>-model-of.ts`. Why: the scaffold proves the wiring once; only
    tests keep it true while the screen grows.
12. **Prove the feature complete before handing it over.** `check-feature.mjs` passes 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

1. **Name it.** Pick the feature name, route id and title in the source language
   ([references/feature-anatomy.md](references/feature-anatomy.md) "Naming the feature",
   [references/routes-and-params.md](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.tsx`
     rendering `src/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`), and `check-feature.mjs` reports it as one `starter-screen` line until it is gone.
2. **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`, `edit` lines, the `drafts <lang>:` lines and the `note` lines (the
   view's frame, a drafted language missing from `reviewLanguages`, a Tabs parent, a `home` other
   than `/`); a `conflict` means 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](examples/scaffold-plan.txt).
3. **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](references/generator-and-checker.md).
4. **Run the feature's tests and gates:** `npx jest src/features/<name> --ci --maxWorkers=2`, then
   `npm run typecheck` and `npx eslint src/features/<name> src/app --max-warnings 0`.
5. **Check it:** `node ${CLAUDE_SKILL_DIR}/scripts/check-feature.mjs . --feature <name>`. Fix every
   `FAIL` line (file, rule, fix; each rule is explained in generator-and-checker "check-feature
   rules") and rerun until `RESULT: PASS`.
6. **Finish what a person decides:** translate the draft keys the plan listed (add each drafted
   language to `contracts/i18n.json` `reviewLanguages` when 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).
7. **Grow it test-first** with [references/test-recipes.md](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/](examples/list-detail/) for a detail
   screen that reads data by a numeric id.
8. **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`.
9. **Rerun `check-feature.mjs`** (add `--orphans` after renaming or deleting part of a feature) until
   `RESULT: 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.tsx` exist; the route
      calls the model hook once and renders the view.
- [ ] A dynamic route has `<f>-params.ts` with 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 has
      `src/ui/screen-frame.tsx` (a plain `View` only 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.json` `reviewLanguages` (or, before that contract exists, the drafts are in the
      report).
- [ ] The route is in `contracts/navigation.json` (and `paramParsers`), the screen in
      `contracts/screens.json`.
- [ ] `npx jest src/features/<f>`, `npm run typecheck` and 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 folder `SKIP 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>` prints `RESULT: PASS`

## Anti-patterns

- **`cp -R src/features/tasks src/features/notes` and 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 and `placeholder-left` catches what is left.
- **Logic in the route file** (`useState`, a query, `useLocalSearchParams`). Move it to the model hook.
- **Calling `useT()` or `useRouter()` 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`, schema `id`). Every link then opens
  the invalid-link state; `params-parser` names it.
- **Going home with `router.navigate('/')` from the invalid-link state.** It stacks a second home;
  `dismissTo('/')`.
- **Adding keys to `en.json` only.** The other languages fail on the phone; every catalog gets them.
- **Overwriting the bootstrap starter by hand-editing `home-screen.tsx` into 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.mjs` and the neighbours' checks.

## Files in this skill

| File | What it is | Read/run when |
|---|---|---|
| [references/feature-anatomy.md](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](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](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](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](examples/scaffold-plan.txt) | A dry run and a write for a dynamic route in the reference app | Workflow step 2 |
| [examples/list-detail/](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, `ScreenFrame` edges, 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-zustand` and `networking-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.

Files in this skill

  • SKILL.md17 KB
  • assets/shared.json155 B
  • assets/template-placeholders.json730 B
  • examples/list-detail/catalog-keys.json620 B
  • examples/list-detail/list-detail-params.ts1.2 KB
  • examples/list-detail/list-detail-state.ts1.2 KB
  • examples/list-detail/use-list-detail-model.ts2.5 KB
  • examples/scaffold-plan.txt3.3 KB
  • references/feature-anatomy.md13.9 KB
  • references/generator-and-checker.md16 KB
  • references/routes-and-params.md9.1 KB
  • references/test-recipes.md6.6 KB
  • scripts/check-feature.mjs32.8 KB
  • scripts/lib/feature-app.mjs3.4 KB
  • scripts/lib/json-format.mjs2 KB
  • scripts/lib/source-scan.mjs35.1 KB
  • scripts/scaffold-feature.mjs34 KB
  • scripts/selftest.mjs2.5 KB
  • templates/src/app/__DETAIL_ROUTE_ID__.tsx815 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.