Use this skill when you need a portable, framework-agnostic architecture style for any React or React Native frontend. Organizes apps into feature modules with page/screen directories, a strict server-state vs UI-state split, barrel-only cross-module imports, co-located styles, and clear component-promotion rules....
Portable skill — readable by Claude Code, OpenCode, Codex, Cursor, Windsurf, and others. This skill describes a structure and a set of rules, not a component library, a state library, or a visual style. It is deliberately global: the same module/page/state model maps onto Next.js (App Router), React + Vite (SPA), Remix, and Expo / React Native, and it works with any state-management and styling stack.
The goal: a codebase where any contributor can instantly answer three questions — "where does this code live?", "what is allowed to import what?", and "is this server state or UI state?" — without asking anyone. The structure makes the answers obvious.
modules/{feature}/ folder with its own pages, components, hooks, state, types, and a single public barrel.@/modules/{feature} and nothing deeper.Everything below is the mechanical application of these five ideas. None of it is tied to a specific library — pick your stack in Sections 4 and 6.
The shape is identical across frameworks; only the routing layer on top differs (see Section 7).
src/
├── app/ or routes/ or navigation/ ← framework routing layer (thin — see §7)
├── modules/ ← feature modules (the heart of the app)
│ └── {feature}/
│ ├── index.ts ← PUBLIC BARREL — the only cross-module entry point
│ ├── README.md ← what this module owns, its routes, its data deps
│ ├── components/ ← components reused by 2+ pages IN THIS MODULE
│ ├── pages/ ← page/screen directories (one per route)
│ │ └── {page}/
│ │ ├── {page}.tsx ← the page/screen component
│ │ ├── {page}.styles.ts ← ALL styling for this page
│ │ ├── index.ts ← re-exports the page component
│ │ ├── components/ ← components used ONLY by this page
│ │ ├── hooks/ ← hooks used ONLY by this page
│ │ ├── constants/
│ │ └── README.md ← route, params, permissions, data deps
│ ├── hooks/ ← data hooks (query/mutation) + module hooks
│ ├── stores/ ← UI/client state store(s) — never server data
│ ├── services/ ← data-access (API calls) for this feature
│ ├── utils/ ← pure module utilities (co-located *.test.ts)
│ ├── constants/
│ └── types/ ← module request/response + view-model types
└── shared/ ← cross-module building blocks
├── components/ ← components used by 2+ MODULES
├── hooks/ ← cross-cutting hooks
├── api-client/ ← one typed client; the only place that talks to the network
├── store/ ← root store wiring (if your state lib needs one — see §4)
├── utils/ ← formatters, cn()/clsx, helpers
├── constants/
└── types/
Every folder that can be empty at scaffold time keeps a .gitkeep so the structure is visible from day one.
A module is a vertical slice of the product (e.g. auth, billing, dashboard, settings). It contains everything that feature needs and exposes a deliberately small surface.
index.ts) is the contractmodules/{feature}/index.ts is the only thing other modules and the routing layer may import from. It re-exports:
// CORRECT — consume the public surface
import { InvoiceListPage, useInvoiceList } from "@/modules/invoice";
// WRONG — reaching into internals couples you to private structure
import { InvoiceListPage } from "@/modules/invoice/pages/invoice-list/invoice-list";
Keep the barrel curated. If something isn't exported, it's private by design. Group exports with short comments (pages, hooks, store, types) — future readers use the barrel as the module's API docs.
Don't create utils modules or components modules. Modules map to product capabilities, not to technical layers. Technical building blocks live in shared/.
Each module's README.md states: what it owns, which routes render its pages, its data dependencies (which endpoints/hooks), and any cross-module rules. This is the first thing a new contributor reads.
A page is a route the router mounts (a "screen" in React Native). It is always a folder, never a loose file — even when it starts as a single component. This keeps growth in place: when the page needs a sub-component or a hook, there is already a home for it.
pages/{page}/
├── {page}.tsx ← the page/screen component
├── {page}.styles.ts ← every style for this page (no inline styles — see §5)
├── index.ts ← export { PageComponent } from "./{page}"
├── components/ ← used ONLY by this page
├── hooks/ ← used ONLY by this page
├── constants/
└── README.md ← route, params, permissions, data deps
The page README is short and high-signal: route path, expected params, required permissions/auth, and the hooks it depends on. It is the contract between the page and the rest of the app.
Why folders from the start: a page that begins as one file inevitably grows a sub-row component, a derived-totals hook, a styles file. If the page is a file, those land in arbitrary places. If the page is a folder, they have an obvious home and the diff stays readable.
Two kinds of state, two homes. Mixing them is the most common architectural failure this skill exists to prevent. The split is mandatory; the libraries are your choice.
| State kind | Examples | Lives in |
|---|---|---|
| Server state | fetched entities, lists, aggregates — anything the API owns | a query/cache layer (e.g. TanStack Query, RTK Query, SWR, Apollo) |
| UI / client state | open dialogs, table filters/sort, wizard step, draft being typed, preview toggles | a client store (e.g. Zustand, Redux Toolkit, MobX, Jotai, Valtio, or React Context) |
Pick one per project and stay consistent. Each maps onto "one store unit per module" cleanly. Note the I interface-naming convention: state interfaces are prefixed with I (e.g. IFeatureUiState).
Zustand — modules/{feature}/stores/{feature}.store.ts
import { create } from "zustand";
export interface IFeatureUiState {
isPreviewOpen: boolean;
filter: string;
togglePreview: () => void;
setFilter: (filter: string) => void;
reset: () => void;
}
const INITIAL_STATE = { isPreviewOpen: false, filter: "" } as const;
export const useFeatureUiStore = create<IFeatureUiState>()((set) => ({
...INITIAL_STATE,
togglePreview: () => set((s) => ({ isPreviewOpen: !s.isPreviewOpen })),
setFilter: (filter) => set({ filter }),
reset: () => set({ ...INITIAL_STATE }),
}));
Redux Toolkit — modules/{feature}/stores/{feature}.slice.ts (registered in shared/store/)
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
export interface IFeatureUiState {
isPreviewOpen: boolean;
filter: string;
}
const initialState: IFeatureUiState = { isPreviewOpen: false, filter: "" };
export const featureUiSlice = createSlice({
name: "featureUi",
initialState,
reducers: {
togglePreview: (s) => {
s.isPreviewOpen = !s.isPreviewOpen;
},
setFilter: (s, action: PayloadAction<string>) => {
s.filter = action.payload;
},
reset: () => initialState,
},
});
MobX — modules/{feature}/stores/{feature}.store.ts
import { makeAutoObservable } from "mobx";
export interface IFeatureUiState {
isPreviewOpen: boolean;
filter: string;
}
export class FeatureUiStore implements IFeatureUiState {
isPreviewOpen = false;
filter = "";
constructor() {
makeAutoObservable(this);
}
togglePreview = () => {
this.isPreviewOpen = !this.isPreviewOpen;
};
setFilter = (filter: string) => {
this.filter = filter;
};
reset = () => {
this.isPreviewOpen = false;
this.filter = "";
};
}
Jotai — modules/{feature}/stores/{feature}.atoms.ts
import { atom } from "jotai";
export const isPreviewOpenAtom = atom(false);
export const filterAtom = atom("");
Whichever you choose, keep the rules in §4.1 constant. The skill cares that server and UI state are separated and that each module owns one store unit — not which library draws the box.
All network access goes through one typed client in shared/api-client/. Modules wrap it in query/mutation hooks and a key factory so caches and invalidation stay consistent.
// modules/invoice/hooks/invoiceKeys.ts — hierarchical key factory (TanStack Query style)
export const invoiceKeys = {
all: ["invoices"] as const,
lists: () => [...invoiceKeys.all, "list"] as const,
list: (params: IListParams) => [...invoiceKeys.lists(), params] as const,
details: () => [...invoiceKeys.all, "detail"] as const,
detail: (id: string) => [...invoiceKeys.details(), id] as const,
} as const;
Invalidating lists() refreshes every filtered page; detail(id) targets one entity. (RTK Query/SWR/Apollo express the same idea with tags/keys.) Components never write raw fetch() — they call useInvoiceList() / useCreateInvoice().
Keep styling out of JSX and out of the component body. Each page or component has a co-located styles file. The rule is constant; the syntax follows your styling stack.
{name}.styles.ts exports named class strings composed with cn() (clsx + tailwind-merge); variants via cva. JSX references styles.header.{name}.module.css / {name}.css.ts; JSX references styles.header.{name}.styles.ts exporting styled components.{name}.styles.ts exporting styled(...) components or a createStyledContext / useStyle token set; reference Tamagui tokens ($background, $space.4) — never hardcoded values inline. Tamagui is the recommended choice when you target both web and React Native from one codebase.{name}.styles.ts exporting StyleSheet.create({...}) (or Nativewind classnames). JSX references styles.header.// invoice-list.styles.ts (Tailwind example)
export const invoiceListStyles = {
page: "flex flex-col gap-8",
header: "flex flex-col gap-1.5",
title: "text-3xl font-semibold tracking-tight",
} as const;
// invoice-list.styles.ts (Tamagui example — works on web AND native)
import { styled, YStack, Text } from "tamagui";
export const InvoiceListPage = styled(YStack, { flex: 1, gap: "$8" });
export const InvoiceListHeader = styled(YStack, { gap: "$1.5" });
export const InvoiceListTitle = styled(Text, {
fontSize: "$8",
fontWeight: "600",
});
No inline style={{...}} literals in the component body, on any stack. Why: styling drifts and duplicates when it lives inline. A co-located styles file gives one place to audit spacing rhythm, theme correctness, and responsive behavior per surface. Document non-obvious choices (accent locks, breakpoints) in comments there.
This skill does not dictate the visual design — pair it with a design/component skill for that. It dictates only where styling lives.
Consistent naming makes the structure self-describing.
I — IFeatureUiState, IInvoiceListParams, IUserProfile. Type aliases (unions, mapped types, primitives) are not prefixed (type SortDirection = "asc" | "desc").PascalCase files and exports — InvoiceListPage.tsx, LineItemRow.tsx.kebab-case directories, the component file matches — pages/invoice-list/invoice-list.tsx.useCamelCase — useInvoiceList, useFeatureUiStore.{feature}.store.ts (Zustand/MobX), {feature}.slice.ts (Redux), {feature}.atoms.ts (Jotai). Hook is use{Feature}{Purpose}Store.{name}.styles.ts co-located with its owner.SCREAMING_SNAKE_CASE values; kebab-case or camelCase files.index.ts.The module/page/state model is constant. Only the thin routing layer on top changes. Pages always live in modules/; the routing layer just mounts them.
src/app/ holds route segments and route groups ((marketing), (app), (public)) for layout/auth boundaries. Route files are thin: import a page component from a module barrel and render it."use client". Providers (query client, store, theme) live in a "use client" boundary.// app/(app)/invoices/page.tsx — thin route file
import { InvoiceListPage } from "@/modules/invoice";
export default function Page() {
return <InvoiceListPage />;
}
src/routes/ (or single router.tsx) declares the route table (React Router / TanStack Router) and maps paths to module page components. Everything is client-side. Wrap the tree once with the query-client and store/theme providers at the app root.app/routes/ stay thin and re-export/mount module page components; loaders/actions delegate to the module's services/. Module boundaries are unchanged.app/) or React Navigation (navigation/). Route/screen files are thin and import screen components from module barrels.pages/{screen}/{screen}.tsx + {screen}.styles.ts.api-client is shared logic and works as-is.StyleSheet, or Nativewind. Keep module logic DOM-free.// app/invoices/index.tsx (Expo Router) — thin screen file
import { InvoiceListScreen } from "@/modules/invoice";
export default InvoiceListScreen;
If you target both web and Expo, push framework-free code (types, validators, formatters, the API client contract) into a shared package consumed by both apps, and prefer Tamagui for components that must render on both. Module boundaries stay the same on both sides.
modules/{feature}/ with index.ts + README.md, not files scattered into shared/.{page}.tsx + {page}.styles.ts + index.ts + README.md), not a loose file.@/modules/{feature}) — no deep internal paths.fetch() in components — only typed data hooks built on the shared client.{name}.styles.ts (Tailwind/CSS Modules/Tamagui/StyleSheet/styled-components).reset.I prefix; components/hooks/files follow §6.A component is born in the narrowest scope that uses it and is promoted only when a second consumer appears. Never pre-place a component "because it might be reused."
| A component used by… | Lives in | Imported as |
|---|---|---|
| Only one page | pages/{page}/components/ |
relative path within the page |
| 2+ pages in one module | modules/{feature}/components/ |
@/modules/{feature} (via barrel) |
| 2+ modules | shared/components/ |
@/shared/... |
| 2+ apps / repos | a published design-system package | the package name |
The same ladder applies to hooks, utils, and constants: local → module → shared → package. Promotion is a deliberate move (update the import sites), not a guess made up front.
Scaffolding a new app: create src/modules/, src/shared/, and the framework routing layer (§7). Add the shared api-client, the query layer, and your chosen client-store provider. Drop a store template into the first module.
Adding a feature: create modules/{feature}/ with the full subfolder set (pages/ components/ hooks/ stores/ services/ utils/ constants/ types/), a curated index.ts, and a README.md. Build the first screen as a page directory.
Deciding where code goes: ask "who consumes this?" → narrowest scope wins (§9). Ask "where did this data come from?" → server = query layer, UI = store (§4).
Reviewing structure: run the checklist in §8. The most valuable catches are state-origin leaks (server data in the client store) and deep cross-module imports (bypassing the barrel) — both erode the architecture fastest.
This skill follows the Anthropic SKILL.md format and is portable across agents. To make it installable and discoverable (e.g. on skills.sh / npx skills):
skills/ directory in a public GitHub repo (path like skills/frontend-architecture/SKILL.md).name and a high-signal description (above) — that description is what discovery indexes match against.npx skills add <org>/<repo> --skill "frontend-architecture".SKILL.md agents can be pointed here from AGENTS.md / CLAUDE.md; Kiro can mirror it as a steering file.