Full-Stack Project Setup
This skill is the durable output of a grilling session that fixed a set of recurring, costly mistakes from past projects: business logic duplicated between React and React Native, slow npm installs, test coverage that quietly became optional, and architecture rules that got lost as vibe-coded projects grew and agents lost context. Every decision below is locked for all new projects unless explicitly re-grilled and changed here.
This is a living document. When a decision changes, update this file and
the matching file(s) under templates/ in the same edit — never let them
drift apart. Full rationale and the honest self-checks/risks for every
decision live in reference/architecture-decisions.md; read that before
changing anything, not just this summary.
Last verified: 2026-09-16. Pinned tool versions live inline in the
templates/ files themselves (package.json, pyproject.toml) — check
those are still current major versions before scaffolding a new project;
static templates go stale silently otherwise.
Locked architecture (summary)
Monorepo tool: Nx for the JS/TS side (web + native + shared packages). FastAPI stays completely outside the Nx graph — its own folder, own tooling (uv/poetry), own CI job. Nx's
enforce-module-boundarieslint rule is what actually stopsapps/nativeimporting web-only code or vice versa — the architecture is enforced by tooling, not memory.Package manager: pnpm. Chosen over bun specifically because this stack is Nx + React Native + Windows dev machines, where pnpm has the deepest, most proven support; bun is faster in isolated benchmarks but stacks three less-battle-tested combinations at once (Bun+Nx, Bun+Metro, Bun+Windows).
Code sharing model: headless core, not universal UI.
packages/core(types, generated API client, zod schemas, pure business rules) andpackages/hooks(alluseX()orchestration, React Query, forms, no JSX) hold every piece of business logic.apps/webandapps/nativecontain only navigation/routing and presentational components. "90-100% shared" means business logic — the UI layer is legitimately written twice, once per platform, by design.Env vars: one root
.env(real file gitignored,.env.examplecommitted) is the single source of truth for values. Each public/client value is duplicated under both bundler-required prefixes (VITE_FOO/EXPO_PUBLIC_FOO, same value) and validated once via a zod schema inpackages/env. Real secrets (DB creds, third-party secret keys) never go in this file — an RN bundle is fully decompilable/public, so anything here is effectively public. A pre-commit check rejects any key added to the shared env file that isn't*_PUBLIC_*/allowlisted.Backend DB stack: SQLAlchemy 2.0 async engine +
asyncpg, Alembic's official async template (alembic init -t async) driving migrations through the same engine. Target: Azure Database for PostgreSQL Flexible Server (not Single Server, which is retired). Hard rule: never rely on implicit lazy-loading on a relationship — always explicitselectinload/joinedload, or it raisesMissingGreenletat runtime.Error contract: RFC 7807 Problem Details (
application/problem+json) from FastAPI's global exception handlers, with a project-definederrors: [{field, message}]extension for field-level validation (not part of the spec — defined here so it's not reinvented per project). Error types are generated intopackages/corefrom the OpenAPI schema — never hand-maintained. Production responses strip internal detail/stack traces; dev responses include them.Async data/loading state: TanStack Query in
packages/hooksis the single source of truth for all async state, called with an identical hook from both a React page and a React Native screen. React Routerloader()functions are allowed on web only as a thin (1-3 line) adapter around a shared query definition (queryClient.ensureQueryData(sharedQueryOptions)) — the real fetch logic is never duplicated; only the trigger (router lifecycle vs. component mount) differs per platform.Coverage gate: 80% lines+branches, repo-wide (greenfield default — diff-coverage is a documented fallback if this skill is ever used to retrofit an existing codebase, not the default). Enforced in layers: a pre-push git hook runs
nx affected -t test --coverage+pytest --covas fast local feedback; CI is the real, non-bypassable gate; the pipeline is ordered so build/deploy never runs unless the test+ coverage stage passed. Coverage is never embedded directly inside the build command itself.1000-line file limit: hard lint-level error, wired into the same pre-push+CI gate — ESLint
max-lines(TS/JS) and a smallpre-commitPython script (scripts/check_file_length.py) for the backend. Generated/ vendored files are exempt via an editable glob list — you can add exceptions; it's a maintained allowlist, not a rigid rule.Test runners: Vitest for the web app and for shared packages (
packages/core,packages/hookshave no native-module dependency, so they don't need Jest). Jest (jest-expopreset) is scoped only toapps/native's own platform-specific code. Coverage from both merges into one repo-wide 80% figure. ESLint restrictsjestglobals toapps/nativeso agents don't reflexively write Jest-style mocks in shared packages.Backend folder structure: domain/feature-based vertical modules —
app/features/<domain>/{router.py, service.py, repository.py, schemas.py, models.py, tests/}— FastAPI's own recommended "bigger applications" pattern. Cross-cutting concerns (auth, db session, config) live inapp/core/, never duplicated per-domain.Skill packaging: this skill is
SKILL.md+ real boilerplate undertemplates/, not prose the agent regenerates from memory each time. Regenerating boilerplate from memory each time is the failure mode this skill exists to remove.Ongoing memory: every scaffolded project gets a generated
CLAUDE.md(fromtemplates/root/CLAUDE.md.template) summarizing these rules, so a Claude Code session opened in that repo months from now still knows the architecture without anyone re-invoking this skill.CLAUDE.mdcarries the "why" (so agents propose correct code the first time); the enforced tooling in items 8-10 is the hard backstop that catches it if they don't.Cross-platform dependency version divergence: React Native legitimately pins tighter/older versions of core packages (React itself, polyfills) than web — that's normal, not a bug to force-fix into one version. Two cases:
- Same package, different version (e.g. React):
packages/coreandpackages/hooksdeclare it as apeerDependencywith a broad range ("react": "^18.2.0 || ^19.0.0"), never a pinned regulardependency. pnpm resolves that peer independently per consumer's own isolated dependency tree, soapps/web's andapps/native's versions never collide; each app keeps its own exact pin in its ownpackage.json. Never hoist a platform-sensitive version to the workspace root. - Not actually the same package — genuinely platform-only libraries
doing the same job (
@react-native-async-storage/async-storagevs.localStorage/idb-keyval; native gesture/animation libs). Shared logic never imports either directly — it defines a small interface and each app injects its own platform implementation. This falls straight out of decision 3: if shared logic seems to need a platform-only package, that's a sign the logic isn't actually platform-agnostic yet, not a reason to compromise the shared-package boundary. - Enforcement: pnpm warns/errors on unmet peer-dependency ranges at
pnpm installtime automatically — the backstop, not a manual check.
- Same package, different version (e.g. React):
Styling: Tailwind class syntax is the single styling vocabulary on both platforms — Tailwind CSS (v4,
@tailwindcss/vite) inapps/web, NativeWind inapps/native. No CSS modules, no styled-components/Emotion on web; no ad-hocStyleSheet.createor inlinestyleobjects for layout in native screens (a one-off dynamic value is the exception). Shared packages never style anything. The native setup (babel/metro/tailwind config,global.css) ships wired intemplates/apps/native/. Tailwind majors diverge per platform (web v4, native v3, which NativeWind v4 is built against) — that's decision #14, not a bug to unify.Backend architecture standardization: ArchUnitPython (
archunitpython) inbackend/tests/test_architecture.py. Matches Nx's module-boundary enforcement on the JS/TS side with static AST-based architecture rules in Python. Hard rules checked viapytest:- Zero dependency cycles (
project_files("app/").should().have_no_cycles()). - Layer boundary integrity:
app/core/(cross-cutting DB session, config, errors) must never depend on domain feature slices inapp/features/. - Metric-based 1000-line file limit:
metrics("app/").count().lines_of_code().should_be_below(1000)running directly as a pytest assertion.
- Zero dependency cycles (
When invoked (scaffolding a new project)
- Ask only what's genuinely project-specific — project name, initial
domain/feature beyond the bundled
todoreference slice, Azure resource naming if known. Do not re-litigate anything in the locked list above; if the user wants to change one of those, that's a re-grill of this skill, not a one-off deviation for a single project (see "Maintaining this skill" below). - Create the target directory and copy the entire
templates/tree into it, preserving structure (templates/root/*→ project root,templates/packages/*→packages/*,templates/apps/*→apps/*,templates/backend/*→backend/*,templates/eslint/*→ wherever the root ESLint config references it). - Replace placeholders in every copied file:
{{PROJECT_NAME}},{{PROJECT_SLUG}}(kebab-case),{{DB_NAME}},{{AZURE_RESOURCE_GROUP}}(leave a clearTODOif not yet known — never invent an Azure resource name silently). - Rename the bundled
todosfeature slice (backendapp/features/todos,packages/hooks/src/queries/todos.ts, the web route and native screen) to the project's actual first domain if the user gave one; otherwise leavetodosin place as the working reference example — it exists specifically to demonstrate the full pattern end-to-end (shared query → thin web loader adapter → native screen → FastAPI vertical slice → Alembic migration). pnpm install,git init,pre-commit install(installs the pre-push hook), install the Python backend's dependencies (uv/poetry perpyproject.toml).- Smoke-test the scaffold before declaring done: run the web and
backend test suites (they should pass with the bundled
todosslice's tests andpytest tests/test_architecture.pyfor ArchUnitPython checks), run the lint/module-boundary check, run the file-length check. A skill that scaffolds a broken starting point is worse than no skill. - Report manual follow-ups explicitly rather than silently skipping them:
Azure PostgreSQL Flexible Server provisioning, real secrets (never
generated), CI secrets configuration in the Git host, and anything left
as
TODOfrom step 3.
Maintaining this skill
You will keep using this skill across projects and keep updating it. When a new lesson comes up on a real project:
- Decide whether it's a global rule change (update this file +
reference/architecture-decisions.md+ the relevanttemplates/file, so every future project gets it) or a one-off for that project only (does not touch this skill). - If it's a global change that reverses or meaningfully qualifies a locked
decision above, treat it as worth a fresh grilling pass (
grill-design) before editing, the same way this skill was built — don't silently overwrite a locked decision based on one project's pressure without checking it against the other 12. - Bump the "Last verified" date at the top of this file whenever you touch
it, and re-check pinned versions in
templates/at the same time.