
React Agents Project Scaffolder
- 11 installs
- 6 repo stars
- Updated July 8, 2026
- openaec-foundation/react-claude-skill-package
Helps with frontend development tasks.
About
react-agents-project-scaffolder is a Claude Code skill in the Frontend Development category.
- react-agents-project-scaffolder
- Frontend Development
- AI-coding skill
React Agents Project Scaffolder by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,658 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/react-claude-skill-package --skill react-agents-project-scaffolderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/react-claude-skill-package ↗ |
What it does
Helps with frontend development tasks.
Files
react-agents-project-scaffolder
Quick Reference
Project Size Classification
| Size | Routes | Components | State Complexity | Team |
|---|---|---|---|---|
| Small | 1-5 | < 20 | Local state only | 1-2 devs |
| Medium | 5-20 | 20-80 | Shared + server state | 3-8 devs |
| Large | 20+ | 80+ | Complex orchestration | 8+ devs |
Generated Stack Summary
| Layer | Small | Medium | Large |
|---|---|---|---|
| Build | Vite + @vitejs/plugin-react | Vite + @vitejs/plugin-react | Vite + @vitejs/plugin-react |
| Language | TypeScript (strict) | TypeScript (strict) | TypeScript (strict) |
| Routing | React Router v6 | React Router v6 | React Router v6 |
| Client State | useState/useReducer | Zustand | Zustand |
| Server State | fetch + useEffect | TanStack Query | TanStack Query |
| Styling | CSS Modules | CSS Modules | CSS Modules |
| Testing | Vitest + RTL | Vitest + RTL | Vitest + RTL + Playwright |
| Linting | ESLint flat config | ESLint flat config | ESLint flat config |
Critical Warnings
NEVER scaffold with Create React App -- it is deprecated and unmaintained. ALWAYS use Vite with @vitejs/plugin-react.
NEVER use default tsconfig.json -- ALWAYS enable strict: true, noUncheckedIndexedAccess: true, and configure path aliases.
NEVER install both @types/react and React 19 -- React 19 ships built-in TypeScript types. ALWAYS check the React version before adding @types/react.
NEVER mix CSS-in-JS runtime libraries (styled-components, emotion) with React Server Components -- they require client-side JavaScript. ALWAYS use CSS Modules or Tailwind for RSC-compatible projects.
NEVER place test setup files inside src/ -- ALWAYS place setup.ts at the project root or in a dedicated test/ directory.
---
Project Size Decision Tree
START: What is the project scope?
│
├─ Prototype / landing page / single feature?
│ └─ → SMALL project scaffold
│
├─ Multi-page app with auth, forms, API integration?
│ └─ → MEDIUM project scaffold
│
├─ Enterprise app with complex state, many teams, SSR needs?
│ └─ → LARGE project scaffold
│
└─ Unsure?
└─ → Default to MEDIUM (scales both directions)SSR Decision
Does the project need SSR or static generation?
│
├─ YES → Use a React framework (Next.js, Remix, TanStack Start)
│ This skill generates the SPA scaffold only.
│ Adapt the patterns below to your framework's conventions.
│
└─ NO → Continue with Vite SPA scaffold below.---
Small Project Structure
project-root/
├── public/
│ └── favicon.svg
├── src/
│ ├── components/
│ │ ├── App.tsx
│ │ ├── App.module.css
│ │ └── {Component}.tsx
│ ├── hooks/
│ │ └── use{Hook}.ts
│ ├── types/
│ │ └── index.ts
│ ├── utils/
│ │ └── {util}.ts
│ ├── main.tsx
│ └── index.css
├── test/
│ └── setup.ts
├── index.html
├── vite.config.ts
├── tsconfig.json
├── tsconfig.node.json
├── eslint.config.js
├── .prettierrc
├── .gitignore
├── .env.example
└── package.json---
Medium Project Structure
project-root/
├── public/
│ └── favicon.svg
├── src/
│ ├── app/
│ │ ├── App.tsx
│ │ ├── router.tsx
│ │ └── providers.tsx
│ ├── components/
│ │ └── ui/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ └── Button.test.tsx
│ │ └── index.ts
│ ├── features/
│ │ └── {feature}/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api.ts
│ │ ├── store.ts
│ │ ├── types.ts
│ │ └── index.ts
│ ├── hooks/
│ │ └── use{Hook}.ts
│ ├── lib/
│ │ ├── api-client.ts
│ │ └── query-client.ts
│ ├── types/
│ │ └── index.ts
│ ├── utils/
│ │ └── {util}.ts
│ ├── styles/
│ │ ├── tokens.css
│ │ └── global.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── test/
│ ├── setup.ts
│ └── test-utils.tsx
├── index.html
├── vite.config.ts
├── tsconfig.json
├── tsconfig.node.json
├── eslint.config.js
├── .prettierrc
├── .gitignore
├── .env.example
└── package.json---
Large Project Structure
project-root/
├── public/
│ └── favicon.svg
├── src/
│ ├── app/
│ │ ├── App.tsx
│ │ ├── router.tsx
│ │ ├── providers.tsx
│ │ └── error-boundary.tsx
│ ├── components/
│ │ └── ui/
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── index.ts
│ ├── features/
│ │ └── {feature}/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api.ts
│ │ ├── store.ts
│ │ ├── types.ts
│ │ └── index.ts
│ ├── hooks/
│ ├── lib/
│ │ ├── api-client.ts
│ │ ├── query-client.ts
│ │ └── auth.ts
│ ├── types/
│ │ ├── api.ts
│ │ └── index.ts
│ ├── utils/
│ ├── styles/
│ │ ├── tokens.css
│ │ └── global.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── test/
│ ├── setup.ts
│ ├── test-utils.tsx
│ └── mocks/
│ └── handlers.ts
├── e2e/
│ ├── {feature}.spec.ts
│ └── playwright.config.ts
├── index.html
├── vite.config.ts
├── tsconfig.json
├── tsconfig.node.json
├── eslint.config.js
├── .prettierrc
├── .gitignore
├── .env.example
└── package.json---
Core Configuration Templates
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
server: {
port: 3000,
strictPort: true,
},
build: {
sourcemap: true,
target: "es2022",
},
});tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "test"],
"references": [{ "path": "./tsconfig.node.json" }]
}tsconfig.node.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"composite": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}---
Dependency Decision Tree
React 18.x Dependencies
ALWAYS install:
├── react@^18.3.0
├── react-dom@^18.3.0
├── @types/react@^18.3.0 (required -- React 18 has no built-in types)
├── @types/react-dom@^18.3.0
├── typescript@^5.5.0
├── vite@^6.0.0
├── @vitejs/plugin-react@^4.0.0
├── eslint@^9.0.0
├── prettier@^3.0.0
└── vitest@^2.0.0
If MEDIUM or LARGE, also install:
├── react-router-dom@^6.28.0
├── @tanstack/react-query@^5.0.0
├── @testing-library/react@^16.0.0
├── @testing-library/jest-dom@^6.0.0
├── @testing-library/user-event@^14.0.0
└── jsdom@^25.0.0
If MEDIUM or LARGE with shared client state:
└── zustand@^5.0.0
If LARGE, also install:
├── @playwright/test@^1.48.0
└── msw@^2.0.0React 19.x Dependencies
ALWAYS install:
├── react@^19.0.0
├── react-dom@^19.0.0
├── (NO @types/react -- React 19 ships built-in types)
├── (NO @types/react-dom -- React 19 ships built-in types)
├── typescript@^5.5.0
├── vite@^6.0.0
├── @vitejs/plugin-react@^4.0.0
├── eslint@^9.0.0
├── prettier@^3.0.0
└── vitest@^2.0.0
Remaining dependencies are the same as React 18.x above.ALWAYS check the target React version before generating package.json. The @types/react difference between React 18 and 19 causes type conflicts if installed incorrectly.
---
Styling Decision
Default: CSS Modules
│
├─ Team already uses Tailwind? → Install tailwindcss@^4.0.0
│ └─ ALWAYS use Tailwind v4 (CSS-first config, no tailwind.config.js)
│
├─ Need design tokens / theming? → CSS Modules + CSS custom properties in tokens.css
│
└─ Building a component library? → CSS Modules (maximum portability)---
Scaffolding Checklist
When generating a React project, ALWAYS complete every item:
1. [ ] Create index.html with <div id="root"></div> and <script type="module" src="/src/main.tsx"></script> 2. [ ] Create vite.config.ts with path aliases and @vitejs/plugin-react 3. [ ] Create tsconfig.json with strict mode and path aliases 4. [ ] Create tsconfig.node.json for build tool files 5. [ ] Create src/main.tsx with createRoot (React 18) or createRoot (React 19) 6. [ ] Create src/app/App.tsx (medium/large) or src/components/App.tsx (small) 7. [ ] Create routing setup if medium/large (src/app/router.tsx) 8. [ ] Create providers wrapper if medium/large (src/app/providers.tsx) 9. [ ] Create test/setup.ts with testing-library matchers 10. [ ] Create eslint.config.js with flat config format 11. [ ] Create .prettierrc with consistent formatting rules 12. [ ] Create .gitignore with node_modules, dist, .env, coverage 13. [ ] Create .env.example with VITE_ prefixed variables 14. [ ] Create package.json with all dependencies and scripts 15. [ ] Verify no @types/react if React 19
---
Reference Links
- references/examples.md -- Complete scaffold output for small, medium, and large projects
- references/patterns.md -- Project structure patterns with rationale
Official Sources
- https://vite.dev/guide/
- https://react.dev/learn/start-a-new-react-project
- https://react.dev/learn/typescript
- https://reactrouter.com/home
- https://tanstack.com/query/latest/docs/framework/react/overview
- https://zustand.docs.pmnd.rs/getting-started/introduction
- https://vitest.dev/guide/
- https://testing-library.com/docs/react-testing-library/intro
- https://eslint.org/docs/latest/use/configure/configuration-files
Scaffold Output Examples
Complete file contents generated by the react-agents-project-scaffolder skill.
---
Small Project Scaffold
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>src/main.tsx (React 18)
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./components/App";
import "./index.css";
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);src/main.tsx (React 19)
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./components/App";
import "./index.css";
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);Note: ThecreateRootAPI is identical in React 18 and 19. The difference is inpackage.jsondependencies (@types/reactneeded for 18, not for 19).
src/components/App.tsx
import styles from "./App.module.css";
export function App(): React.ReactElement {
return (
<div className={styles.container}>
<h1>My App</h1>
</div>
);
}src/components/App.module.css
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}src/index.css
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}src/types/index.ts
// Shared application types
// Add project-specific types heretest/setup.ts
import "@testing-library/jest-dom/vitest";.gitignore
node_modules/
dist/
.env
.env.local
*.local
coverage/
.DS_Store.env.example
VITE_API_URL=http://localhost:8080/api.prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}eslint.config.js
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
}
);package.json (React 18)
{
"name": "my-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"jsdom": "^25.0.0",
"prettier": "^3.4.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.0",
"vitest": "^2.1.0"
}
}package.json (React 19)
{
"name": "my-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"jsdom": "^25.0.0",
"prettier": "^3.4.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.0",
"vitest": "^2.1.0"
}
}Note: React 19 has NO@types/reactor@types/react-dom-- types are built in.
---
Medium Project Scaffold (Additional Files)
All small project files apply. These are the additional files for medium projects.
src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app/App";
import "./styles/global.css";
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("Root element not found");
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);src/app/App.tsx
import { Providers } from "./providers";
import { AppRouter } from "./router";
export function App(): React.ReactElement {
return (
<Providers>
<AppRouter />
</Providers>
);
}src/app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
});
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps): React.ReactElement {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}src/app/router.tsx
import { createBrowserRouter, RouterProvider, Outlet } from "react-router-dom";
function RootLayout(): React.ReactElement {
return (
<div>
<header>{/* Navigation */}</header>
<main>
<Outlet />
</main>
</div>
);
}
function HomePage(): React.ReactElement {
return <h1>Home</h1>;
}
function NotFoundPage(): React.ReactElement {
return <h1>404 - Page Not Found</h1>;
}
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: "*", element: <NotFoundPage /> },
],
},
]);
export function AppRouter(): React.ReactElement {
return <RouterProvider router={router} />;
}src/lib/api-client.ts
const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8080/api";
interface RequestOptions extends Omit<RequestInit, "body"> {
body?: unknown;
}
export async function apiClient<T>(
endpoint: string,
options: RequestOptions = {},
): Promise<T> {
const { body, headers, ...rest } = options;
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
...rest,
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}src/lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});
}src/styles/tokens.css
:root {
/* Colors */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-border: #e2e8f0;
--color-error: #dc2626;
--color-success: #16a34a;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
/* Typography */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif;
--font-mono: "Fira Code", "Cascadia Code", Consolas, monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
/* Borders */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}src/styles/global.css
@import "./tokens.css";
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
color: var(--color-text);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}src/features/{feature}/store.ts (Zustand example)
import { create } from "zustand";
interface FeatureState {
items: string[];
isLoading: boolean;
addItem: (item: string) => void;
removeItem: (index: number) => void;
setLoading: (loading: boolean) => void;
}
export const useFeatureStore = create<FeatureState>((set) => ({
items: [],
isLoading: false,
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (index) =>
set((state) => ({ items: state.items.filter((_, i) => i !== index) })),
setLoading: (loading) => set({ isLoading: loading }),
}));src/components/ui/Button/Button.tsx
import type { ButtonHTMLAttributes } from "react";
import styles from "./Button.module.css";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
}
export function Button({
variant = "primary",
size = "md",
className,
children,
...props
}: ButtonProps): React.ReactElement {
return (
<button
className={`${styles.button} ${styles[variant]} ${styles[size]} ${className ?? ""}`}
{...props}
>
{children}
</button>
);
}src/components/ui/Button/Button.module.css
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
font-family: inherit;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primary {
background-color: var(--color-primary);
color: white;
}
.primary:hover:not(:disabled) {
background-color: var(--color-primary-hover);
}
.secondary {
background-color: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.ghost {
background-color: transparent;
color: var(--color-text);
}
.sm {
padding: var(--space-xs) var(--space-sm);
font-size: var(--text-sm);
}
.md {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-base);
}
.lg {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-lg);
}src/components/ui/Button/Button.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("calls onClick handler", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});
it("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
});test/test-utils.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderOptions } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
interface WrapperProps {
children: ReactNode;
}
function AllProviders({ children }: WrapperProps): React.ReactElement {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) {
return render(ui, { wrapper: AllProviders, ...options });
}
export { screen, waitFor, within } from "@testing-library/react";
export { default as userEvent } from "@testing-library/user-event";package.json additions (Medium -- add to Small base)
{
"dependencies": {
"react-router-dom": "^6.28.0",
"@tanstack/react-query": "^5.62.0",
"zustand": "^5.0.0"
}
}---
Large Project Scaffold (Additional Files)
All medium project files apply. These are the additional files for large projects.
src/app/error-boundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("ErrorBoundary caught:", error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div role="alert">
<h2>Something went wrong</h2>
<pre>{this.state.error?.message}</pre>
</div>
)
);
}
return this.props.children;
}
}test/mocks/handlers.ts (MSW)
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/health", () => {
return HttpResponse.json({ status: "ok" });
}),
];e2e/playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: ".",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});package.json additions (Large -- add to Medium base)
{
"scripts": {
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"msw": "^2.7.0"
}
}Project Structure Patterns
Rationale and guidelines for React project organization decisions.
---
Pattern 1: Feature-Based Organization (Medium/Large)
Structure
src/features/
├── auth/
│ ├── components/
│ │ ├── LoginForm.tsx
│ │ └── SignupForm.tsx
│ ├── hooks/
│ │ └── useAuth.ts
│ ├── api.ts
│ ├── store.ts
│ ├── types.ts
│ └── index.ts # Public API barrel export
├── dashboard/
│ ├── components/
│ ├── hooks/
│ ├── api.ts
│ ├── store.ts
│ ├── types.ts
│ └── index.tsRules
- ALWAYS co-locate feature-specific components, hooks, API calls, and types within the feature directory
- ALWAYS use an
index.tsbarrel file to define the public API of each feature - NEVER import internal feature files from outside the feature -- ALWAYS import from
@/features/{name} - NEVER create cross-feature dependencies without going through the public API
Rationale
Feature-based organization scales because:
- Adding a new feature means adding a new directory, not modifying shared directories
- Deleting a feature is a single directory removal
- Each feature is self-contained and independently testable
- Code review scope is clear per feature
---
Pattern 2: Flat Organization (Small)
Structure
src/
├── components/
│ ├── App.tsx
│ ├── Header.tsx
│ └── TodoList.tsx
├── hooks/
│ └── useTodos.ts
├── types/
│ └── index.ts
└── utils/
└── format.tsRules
- ALWAYS use flat organization for projects with fewer than 20 components
- NEVER create feature directories for a small project -- it adds unnecessary nesting
- ALWAYS migrate to feature-based when component count exceeds 20 or team exceeds 3
Rationale
Flat structure works for small projects because:
- Every file is reachable within two levels of nesting
- No barrel files to maintain
- Lower cognitive overhead for solo developers
---
Pattern 3: Shared UI Components
Structure
src/components/ui/
├── Button/
│ ├── Button.tsx
│ ├── Button.module.css
│ └── Button.test.tsx
├── Input/
│ ├── Input.tsx
│ ├── Input.module.css
│ └── Input.test.tsx
├── Modal/
│ ├── Modal.tsx
│ ├── Modal.module.css
│ └── Modal.test.tsx
└── index.ts # Re-exports all UI componentsRules
- ALWAYS co-locate component, styles, and tests in the same directory
- ALWAYS name the directory and component file identically (PascalCase)
- ALWAYS export shared UI components from
src/components/ui/index.ts - NEVER put business logic in shared UI components -- they MUST be purely presentational
- NEVER import feature-specific types into shared UI components
Rationale
Component co-location means:
- Related files are always together (no hunting across directories)
- Deleting a component removes all its artifacts
- Test files are discoverable without separate test directory mirroring
---
Pattern 4: Path Aliases
Configuration
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}// vite.config.ts
import { resolve } from "path";
export default defineConfig({
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});Rules
- ALWAYS configure the
@alias pointing tosrc/ - ALWAYS configure both
tsconfig.jsonandvite.config.ts-- they are independent - ALWAYS use
@/for imports that cross directory boundaries - NEVER use relative paths with more than one
../level -- use@/instead - NEVER create additional aliases unless the project has multiple source roots
Import Examples
// GOOD -- clear origin
import { Button } from "@/components/ui";
import { useAuth } from "@/features/auth";
import { apiClient } from "@/lib/api-client";
// GOOD -- relative for sibling files
import { LoginForm } from "./LoginForm";
import styles from "./Login.module.css";
// BAD -- deep relative paths
import { Button } from "../../../components/ui/Button";---
Pattern 5: Test Organization
Structure
project-root/
├── src/
│ └── components/ui/Button/
│ ├── Button.tsx
│ └── Button.test.tsx # Unit tests co-located
├── test/
│ ├── setup.ts # Vitest setup file
│ └── test-utils.tsx # Custom render with providers
└── e2e/ # Large projects only
├── auth.spec.ts
└── playwright.config.tsRules
- ALWAYS co-locate unit tests next to the source file (
Component.test.tsx) - ALWAYS place test setup and utilities in the top-level
test/directory - NEVER place
setup.tsinsidesrc/-- it pollutes the application source - ALWAYS create a
test-utils.tsxthat wraps render with all providers (QueryClient, Router, etc.) - ALWAYS use the custom
renderWithProvidersin tests instead of barerender - NEVER mock React hooks directly -- test behavior through the component
Vitest Configuration
// vite.config.ts
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
css: true,
},
});---
Pattern 6: Environment Variables
Rules
- ALWAYS prefix client-side environment variables with
VITE_-- Vite only exposes prefixed variables - ALWAYS create
.env.examplewith placeholder values and commit it - NEVER commit
.envor.env.local-- they MUST be in.gitignore - ALWAYS access variables via
import.meta.env.VITE_* - ALWAYS validate required environment variables at startup
Validation Pattern
// src/lib/env.ts
function getEnvVar(key: string): string {
const value = import.meta.env[key];
if (!value) {
throw new Error(`Missing environment variable: ${key}`);
}
return value;
}
export const env = {
apiUrl: getEnvVar("VITE_API_URL"),
} as const;---
Pattern 7: Barrel Exports
Rules
- ALWAYS use barrel exports (
index.ts) for feature and UI component directories - NEVER re-export internal implementation details -- only the public API
- NEVER use barrel exports in leaf directories with fewer than 3 files
Example
// src/features/auth/index.ts
export { LoginForm } from "./components/LoginForm";
export { SignupForm } from "./components/SignupForm";
export { useAuth } from "./hooks/useAuth";
export type { User, AuthState } from "./types";
// Do NOT export: ./api.ts internals, ./store.ts internals---
Pattern 8: State Management Layers
Decision Matrix
| State Type | Location | Tool |
|---|---|---|
| Component UI state (open/close, form inputs) | Component | useState |
| Complex component state (reducers) | Component | useReducer |
| Shared client state (theme, sidebar, user prefs) | Global store | Zustand |
| Server/async state (API data, caching) | Query layer | TanStack Query |
| URL state (filters, pagination, search) | URL | React Router useSearchParams |
Rules
- NEVER put server state in Zustand -- ALWAYS use TanStack Query for API data
- NEVER put UI state in global stores -- ALWAYS keep it local to the component
- ALWAYS separate client state (Zustand) from server state (TanStack Query)
- NEVER duplicate URL-derivable state in a store -- read from
useSearchParams
---
Pattern 9: Providers Composition
Rules
- ALWAYS compose providers in a single
Providerscomponent (src/app/providers.tsx) - ALWAYS order providers from outermost (most general) to innermost (most specific)
- NEVER nest providers inside feature components -- they belong at the app level
Recommended Order
export function Providers({ children }: { children: ReactNode }) {
return (
<ErrorBoundary> {/* 1. Error catching (outermost) */}
<QueryClientProvider> {/* 2. Data layer */}
<AuthProvider> {/* 3. Authentication */}
<ThemeProvider> {/* 4. UI theming */}
{children}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
);
}---
Pattern 10: Routing Architecture
Rules
- ALWAYS use
createBrowserRouterfrom React Router v6 -- it enables data loading and error boundaries per route - ALWAYS define routes in a single
router.tsxfile for small/medium projects - NEVER use the legacy
<BrowserRouter>+<Routes>pattern for new projects - ALWAYS include a catch-all 404 route as the last child
- ALWAYS use layout routes with
<Outlet />to share navigation and layout
Route Organization (Large Projects)
// src/app/router.tsx
import { createBrowserRouter } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
lazy: () => import("@/app/layouts/RootLayout"),
children: [
{ index: true, lazy: () => import("@/features/home") },
{
path: "dashboard",
lazy: () => import("@/features/dashboard"),
children: [
{ index: true, lazy: () => import("@/features/dashboard/Overview") },
{ path: "settings", lazy: () => import("@/features/dashboard/Settings") },
],
},
{ path: "*", lazy: () => import("@/app/pages/NotFound") },
],
},
]);- ALWAYS use
lazy()for route-level code splitting in medium/large projects - NEVER eagerly import all route components -- it defeats code splitting