.agents/skills/mern/SKILL.md
A strict 2026 MERN stack engineering playbook with comprehensive code examples for React, Node, Express, and MongoDB.
npx skillsauth add abhisheksuhag/best-practice-mern mernInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Modern Best Practices for Scalability, Maintainability & Performance. Basically: "If someone joins my startup tomorrow — this is how we write React, Node, Express, Mongo in 2026 so that the codebase doesn't become a nightmare in 12 months." Not theory. Not tutorials. Just: what to do / what not to do in production-grade MERN apps.
This section answers: How should we write React apps in 2026 so they remain scalable after 50+ features?
✅ Use:
// Example:
const useCategories = () => {
return useQuery(categoryQueryKeys.list(), fetchCategories)
}
// UI stays clean:
const CategoryList = () => {
const { data } = useCategories()
}
❌ Avoid:
If your component fetches data, filters it, sorts it, validates it, and mutates it, that is NOT a component anymore. That's a mini-backend 😭. Move logic to: hooks/, queries/, store/, utils/.
✅ Always write like this:
type CategoryFormProps = {
onSubmit: (data: CategoryInput) => void // (or data: Category)
}
const CategoryForm = ({ onSubmit }: CategoryFormProps) => {}
❌ Never use: const CategoryForm: React.FC<Props>
Why? React.FC adds implicit children, messes with generics (bad generic inference), breaks defaultProps typing, makes inference harder, and makes DevTools debugging messier.
Modern React has 4 types of state:
| State Type | Example | Tool | |---|---|---| | UI State | modal open | Zustand | | Server State | users from DB | Tanstack Query | | Form State | login form | React Hook Form | | URL State | page=2 | React Router / nuqs | | Global Auth | — | Zustand + Persist |
🔥 GOLDEN RULE: Server State ≠ Client State
❌ NEVER: Store API response in Zustand, store React Query data in Redux, or duplicate backend data locally. Let React Query own server data lifecycle: caching, retries, background refetch, deduping, and stale logic. Avoid Redux unless enterprise-level scale, and avoid useState for shared state.
❌ Old Pattern: Fetch inside useEffect
useEffect(() => {
fetch('/api/users')
}, [])
This causes: race conditions, double fetching, stale UI, no caching, bad retries, and manual loading states.
✅ New Standard: Always use query.ts, mutation.ts, and queryKeys.ts. Use useQuery(), useMutation(), Query Key Factory pattern, Optimistic Updates, and Suspense Mode.
// Example:
export const categoryQueryKeys = {
all: ['categories'] as const,
list: () => [...categoryQueryKeys.all, 'list'] as const,
}
export const useCategories = () =>
useQuery({
queryKey: categoryQueryKeys.list(),
queryFn: fetchCategories,
staleTime: 5 * 60 * 1000
})
✅ Use:
src/
modules/
category/
components/
hooks/
api/
types/
category.query.ts
category.mutation.ts
category.store.ts
❌ Avoid: Root-level components/, pages/, utils/, services/. Type-based structure doesn't scale. Feature-based = modular.
✅ Use: React Hook Form and Zod Resolver.
❌ Never: Manage form state manually, use useState for inputs, or validate in onSubmit.
✅ Use: Lazy loading for routes (with Suspense), React.memo only when needed, useCallback only for child prop stability (handlers passed down), Virtualized Lists (react-virtual) for large tables.
❌ Avoid: Premature memoization (memoizing everything), inline anonymous functions/heavy computations in heavy lists/JSX, derived state stored in state.
const [total, setTotal] = useState(cart.reduce(...))const total = useMemo(() => cart.reduce(...), [cart])Use Zustand ONLY for: auth state, theme, UI toggles, and local preferences.
Never: backend data, form inputs, server responses.
Never call axios inside component.tsx. Always use api/category.api.ts.
✔ Hooks > Classes ✔ Tanstack Query for server state ✔ Zustand for UI state ✔ React Hook Form for forms ✔ Zod for validation ✔ Feature-based folder structure ✔ API layer abstraction ✔ No fetch in useEffect ✔ No React.FC ✔ No global server state
Classic MVC (route → controller → model) looks nice in tutorials but becomes controllers with 300 lines, models called directly, business logic duplicated in 7 controllers, impossible testing, and tight coupling in real apps. Avoid MVC-only backend (doesn't scale well).
✅ Use This Instead: Layered Architecture OR Domain Driven Modular Architecture. route → controller → service → repository → database. Each layer has ONLY ONE responsibility.
| Layer | Responsibility | |---|---| | Route | define endpoint | | Controller | HTTP handling | | Service | business logic | | Repository | DB interaction | | Model | schema only |
🚨 Rule: Controllers should NEVER talk to database directly. If you see User.find() inside controller → architecture violation.
❌ Avoid: controllers/, models/, routes/, services/. This type-based structure breaks at scale.
✅ Use Domain-Based Structure:
src/
modules/
user/
user.route.ts
user.controller.ts
user.service.ts
user.repository.ts
user.schema.ts
user.dto.ts
Everything related to "user" lives in one module. Now: features are isolated, testing becomes easy, onboarding is faster, and less merge conflicts.
Controllers are the translation layer between HTTP world and business world.
✅ Controller SHOULD: Validate input, Call service, Return response.
❌ Controller SHOULD NOT: Query DB, Run business logic, Transform domain logic, Call external APIs, Perform calculations.
const user = await User.findById(id); user.balance += 200; await user.save()await userService.addBalance(id, 200)Service Layer contains: business rules, workflows, calculations, multi-db operations, external integrations.
Example: await walletService.transferMoney(sender, receiver, amount). Inside: validate balance, debit, credit, transaction logging, rollback logic. None of this belongs in controller.
Service should NOT care if you are using Mongo, Postgres, Redis, or Firestore. Repository handles DB.
Example: userService → userRepository → mongoose. Now: You can replace Mongo later without rewriting business logic.
❌ Avoid: Nested awaits or blocking operations (sync fs, crypto).
✅ Use: async/await everywhere.
// Promise.all for parallel tasks
await Promise.all([
save(),
sendMail(),
log()
])
Parallel execution = faster APIs.
Never: throw raw strings, send stack traces to client, or write try/catch in every controller.
✅ Create Custom Error Class and Global Middleware:
class AppError extends Error {
constructor(message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
// Controller:
next(new AppError('User not found', 404))
Use Error Codes (not only messages).
Never trust req.body. Use DTOs (e.g., createUser.dto.ts) to ensure validation, shape consistency, type safety, and prevent overposting attacks.
// Controller:
const dto = createUserSchema.parse(req.body)
await userService.create(dto)
Use Zod; avoid manual validation.
Never use console.log() in production. Use Pino (fastest) for structured JSON logs.
{
"level": "info",
"service": "auth",
"userId": "123"
}
Logs should track: requestId, userId, module, latency.
Never await sendEmail() inside a signup API. The user should not wait for the email service.
Use BullMQ, Worker Threads, or Message Queue. Flow: signup → queue email job → API returns immediately.
Avoid import userRepository inside service directly.
Instead, inject dependency: new UserService(userRepository). Now it is testable, replaceable, and loosely coupled.
Never use process.env.JWT_SECRET everywhere inside 25 files. Use config/env.ts and config/db.ts to export validated config once.
✔ Domain-based modules ✔ Controller thin ✔ Service = business logic ✔ Repository = DB access ✔ DTO validation ✔ Custom Error System ✔ Global Error Middleware ✔ Structured logging ✔ Background jobs for async tasks ✔ Promise.all for parallel work ✔ Dependency Injection ✔ Central config management
Express is NOT your backend logic. Express is your Transport Layer.
It should only: receive request, validate, authenticate, pass to controller, send response. Nothing else.
Every API request should follow:
Request
↓ Global Middleware
↓ Auth Middleware
↓ Validation Middleware
↓ Controller
↓ Service
↓ Repository
↓ DB
↓ Response Formatter
If this flow breaks → codebase becomes unpredictable.
❌ Never: Put logic in routes, DB calls inside controller, or business logic inside route.
router.post('/user', async (req,res)=>{ const user = await User.create(req.body) })✅ Routes Should Only: Map endpoint → middleware → controller.
router.post(
'/',
validate(createUserSchema),
authMiddleware,
userController.createUser
)
Routes = traffic police 🚦 They just direct the request. Controllers validate input, call service, return response. Services handle business logic and DB calls.
Your Express app should have 3 middleware types:
| Type | Purpose | |---|---| | Global | CORS, Helmet | | Route | Auth | | Feature | Validation |
app.use(cors()); app.use(helmet()); app.use(requestLogger)router.use(authMiddleware)router.post('/', validate(schema), controller)Never check manual req.body in controllers (e.g., if(!req.body.email)).
✅ Use: Zod / Joi and Celebrate middleware.
export const validate =
(schema) =>
(req, res, next) => {
schema.parse(req.body)
next()
}
Now controller only receives valid data.
Never decode JWT inside controller (e.g., jwt.verify()).
✅ Create: auth.middleware.ts. Attach user to request: req.user = decodedUser. Now downstream layers use: req.user.id. Clean separation.
Avoid: res.json(user), res.send(data), res.status(200).json() — Different devs = different response shapes 😭
✅ Create: response.util.ts. Example: res.success(data), res.error(message).
Now every API response becomes predictable frontend handling:
{
"success": true,
"data": {}
}
Never use /api/users. Use /api/v1/users. Later: /api/v2/users. Avoids breaking mobile apps, old clients, and frontend crashes.
Use Helmet, CORS config, JWT Rotation, Bcrypt with salt rounds ≥ 10, and express-rate-limit. Avoid storing secrets in code or sending stack traces to client.
Protect: login, OTP, public endpoints. Example: Attach loginLimiter → router.post('/login', loginLimiter, controller).
Never use res.status(500) inside controller.
next(new AppError('Not found', 404))error.middleware.ts handles formatting, status code, logging.Avoid try/catch everywhere. Use asyncHandler(controller) to wrap controller once for a cleaner codebase.
Track: requestId, userId, route, latency. Attach requestId: req.id = uuid(). Now logs become traceable in production.
| Route Type | Middleware | |---|---| | Public | none | | Private | auth | | Admin | auth + role |
Example: authorize('admin').
✔ No logic in routes ✔ Validation middleware ✔ Auth middleware ✔ Versioned APIs ✔ Global error handler ✔ Async wrapper ✔ Response formatter ✔ Request logging ✔ Rate limiting ✔ Role-based route protection
MongoDB is a Query-first database. Not relation-first, normalization-first, or table-first. You design based on: How the data will be read most frequently.
✅ EMBED WHEN:
✅ REFERENCE WHEN:
🚨 Avoid: Unbounded arrays (e.g., comments: []). This can break Mongo's 16MB document limit later.
Use Hybrid when needed. Embed frequently read fields, reference heavy dynamic data.
Example order: userId, total, itemsSummary (embedded), itemsRef (referenced). Fast reads + scalable writes.
Always use timestamps: true and versionKey: false. Also use toJSON: { virtuals: true } and toObject: { virtuals: true } — needed for computed fields.
If your query filters → it needs an index. Avoid find() without index.
userId + createdAt needed for pagination and dashboard queries.🚨 Avoid: Indexing everything. Each index increases write time and consumes memory.
Normal Mongoose returns heavy document instances. You don't need that for APIs.
Always use .lean() for reads (e.g., User.find().lean()). Lean is 30–50% faster, uses lower memory, and returns plain JSON.
Avoid lean when: using virtuals, using middleware, or modifying document.
Never use skip(50000) — Mongo will scan 50k docs first 😭
Use Cursor Pagination based on createdAt or _id.
// Pattern: Scales infinitely.
find({ createdAt: { $lt: lastSeen } })
.limit(10)
.sort({ createdAt: -1 })
Aggregation is Mongo's SQL JOIN + GROUP BY equivalent.
Always:
$match first / early.$project early to reduce payload.$facet for dashboards / analytics.$lookup only when necessary / sparingly.🚨 Avoid: Aggregation without index support or pipeline before filtering ($lookup → $match explodes memory usage).
Never use .populate('orders') for lists (Populate = hidden join). Use aggregation or manual join for large datasets. Populate okay only for detail pages.
Never use User.deleteOne(). Avoid hard deletes in production apps.
Use deletedAt: Date | null. Query: { deletedAt: null }.
Use for fullName, computed totals, derived stats. Example: userSchema.virtual('fullName'). No DB storage needed.
Never use User.find() blindly. Use .select('name email') for less payload → faster APIs.
Use for hashing password, audit logs, timestamps, cascading soft delete. Avoid heavy logic inside middleware.
Use for payments, wallet updates, inventory deduction with session.startTransaction(). Never rely on Mongo atomicity for multi-document ops.
✔ Embed small related data ✔ Reference growing data ✔ Hybrid for balance ✔ Always index query fields ✔ Use lean() for reads ✔ Cursor pagination ✔ Match early in aggregation ✔ Avoid populate for lists ✔ Use soft delete ✔ Use projections ✔ Use transactions for multi-doc ops ✔ Virtuals for computed data
If this is not strict → your whole type safety is fake.
✅ Must Enable:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
❌ Avoid: const name: string = "Abhishek" (Redundant).
✅ Use: const name = "Abhishek" (Let TS infer internally).
🚨 Exception: Always type function parameters, public API responses, DTOs, service returns.
interface when: defining object shapes, extendable domain models, class contracts.interface User { id: string; email: string }
type when: unions, tuples, mapped types, utility composition.type Role = 'admin' | 'user'
any❌ Bad: function process(data: any) — Removes autocomplete, compile checks, runtime safety.
✅ Use: unknown and narrow later.
function parse(data: unknown) {
if (typeof data === "string") {
return data.toUpperCase()
}
}
Backend should NEVER accept req.body directly. Use DTO validation via Zod.
// createUser.dto.ts
export interface CreateUserDTO {
email: string
password: string
}
Now: controller safe, service safe, DB safe. Prevents overposting attack (User sending admin: true manually).
Always type API responses.
❌ Bad: async function getUser()
✅ Good: async function getUser(): Promise<User>
Frontend: useQuery<User>(). Full stack type sync achieved.
Use in repositories, API clients, services.
async function fetchData<T>(url: string): Promise<T>
Now reusable across users, orders, products.
import { User } from './types'import type { User } from './types' — Prevents runtime bundle pollution.const ROLES = ['admin', 'user'] as const
type Role = typeof ROLES[number]
Strongest possible union.
Needed for external APIs, unknown input, JSON parsing.
function isUser(value: unknown): value is User {
return typeof value === 'object'
}
user.name.lengthuser?.name ?? "Anonymous"❌ Avoid: Recursive mapped types everywhere. Slows IDE, build time, compile time.
✅ Prefer: Partial<User>, Pick<User, 'id'>, Readonly<User>.
interface PaymentGateway {
charge(amount: number): Promise<boolean>
}
Service depends on interface — not implementation. Testable architecture achieved.
Never use a generic types.ts. Use domain based typing:
user/user.types.ts
user/user.dto.ts
Use @ts-expect-error to test invalid cases.
✔ Strict mode enabled ✔ Inference > explicit types ✔ Interface for models ✔ Type for unions ✔ No any ✔ unknown for external data ✔ DTO validation ✔ Typed async returns ✔ Generic APIs ✔ type-only imports ✔ const assertions ✔ Type guards ✔ Safe null handling ✔ Domain based typing ✔ Avoid deep mapped types
development
Use this skill when building, reviewing, or architecting MERN stack applications (MongoDB, Express, React, Node.js). Triggers include: questions about React component patterns, state management, data fetching, TypeScript setup, Express API design, Mongoose schema design, folder structure, auth patterns, performance, or any combination of these. Covers pragmatic production standards — what to use, what to skip, and why — across the full stack.
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
development
Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.