/SKILL.md
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.
npx skillsauth add abhisheksuhag/best-practice-mern mern-stackInstall 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.
Pragmatic standards for scalable, maintainable production apps. What to do, what to skip, and why — with honest tradeoffs included.
Do:
useUser, useCart, useFilters)Avoid:
Type-based (works well up to ~50 features, small team):
src/
components/ ← shared/reusable UI
pages/ ← route-level pages
hooks/ ← custom hooks
utils/ ← helpers (cn, formatDate)
types/ ← shared TypeScript types
config/ ← constants, axios instance
Feature-based (switch when large team / 50+ features):
src/
modules/
user/
components/
hooks/
types/
Pick one and be consistent. Type-based is simpler to start. Only migrate to feature-based when engineers own different modules and are stepping on each other.
// ✅ Explicit prop types, no React.FC
type UserCardProps = {
userId: string
onDelete: (id: string) => void
}
const UserCard = ({ userId, onDelete }: UserCardProps) => { }
Never use React.FC<Props>. It adds implicit children, breaks generic component inference, and makes DevTools messier. No benefit.
| State Type | Tool | |---|---| | Server data (API responses) | TanStack Query | | Auth / current user | TanStack Query cache | | Form state | React Hook Form | | URL state | React Router params | | UI globals (theme, modals) | Zustand — only if needed |
Golden rule: Don't duplicate server state into a global client store. TanStack Query owns server data — caching, deduplication, retries, background refetch. Treat its cache as a read layer.
Zustand is optional. If your auth and most global state live in the query cache, you may not need Zustand at all. Add it only for real client-only global state that doesn't belong in a query.
Always use useQuery and useMutation. Never useEffect + fetch.
// Query key constants — centralize them
export const USER_QUERY_KEY = ["current-user"] as const
const { data: user, isLoading } = useQuery<User>({
queryKey: USER_QUERY_KEY,
queryFn: ({ signal }) => api.get("/auth/me", { signal }).then(r => r.data),
retry: false,
})
Elaborate Query Key Factory patterns (categoryQueryKeys.list()) are useful only when you have many related queries that need coordinated invalidation. For most apps, simple constant arrays are enough.
Use TailwindCSS with a cn() utility for conditional classes:
// utils/cn.ts
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export default function cn(...inputs: Parameters<typeof clsx>) {
return twMerge(clsx(inputs))
}
Vanilla CSS is fine for small projects. Tailwind wins at team scale — no naming battles, no dead CSS, utility classes are self-documenting.
Always use React Hook Form + Zod resolver:
const schema = z.object({ email: z.string().email(), password: z.string().min(8) })
const { register, handleSubmit } = useForm({ resolver: zodResolver(schema) })
Never manage form state via useState per field. Never validate manually in onSubmit.
React.memo — only after profiling confirms unnecessary re-rendersuseCallback — only to stabilize callback references passed to memoized childrenuseMemo — for genuinely expensive computations, not "just in case"Premature memoization adds noise without benefit. Profile before optimizing.
React.FCuseEffect for data fetchinguseState for server datacn() utility for conditional Tailwind classesThe full layered architecture (Controller → Service → Repository → DB) is often recommended but adds boilerplate that only pays off at scale. Choose based on where you are:
Thin controllers (startup to mid-size):
Route → Middleware → Controller → Model → DB
Controllers validate input, call the ORM directly, return response. Works well when controllers are focused and ~20–50 lines.
Service layer (extract when needed):
Route → Middleware → Controller → Service → Model → DB
Extract a service when: business logic is shared across multiple controllers, complex multi-step operations need isolated testing, or you're running payment/financial workflows.
Repository layer (rarely necessary): Add only if you need to swap databases or heavily mock DB calls in unit tests. Most MERN apps don't swap databases.
A 3-layer architecture on a 5-controller CRUD app is over-engineering. Add layers as the codebase earns them.
Type-based (simple, small-medium apps):
src/
controllers/
auth/
Login.ts
Logout.ts
user/
GetUser.ts
routes/
auth.routes.ts
user.routes.ts
models/
User.model.ts
middlewares/
configs/
shared/ ← email, s3, token utilities
jobs/ ← background jobs
utils/
Domain-based (large team / many features):
src/
modules/
user/
user.route.ts
user.controller.ts
user.service.ts
user.schema.ts
Validate in the route layer, before the controller ever runs:
// validateMiddleware.ts
function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({ body: req.body, query: req.query, params: req.params })
next()
} catch (err) {
next(err) // ZodError handled by global error middleware
}
}
}
// auth.routes.ts
router.post("/login",
validate(z.object({
body: z.object({ email: z.string().email(), password: z.string().min(8) })
})),
loginController,
)
Separate DTO files are useful when the same schema is reused across multiple routes. For single-use schemas, inline Zod is cleaner.
Global error middleware handles everything. No try/catch duplication:
// errorMiddleware.ts — must be last in Express chain
const errorMiddleware = (err: unknown, req: Request, res: Response, _: NextFunction) => {
if (err instanceof ZodError) { res.status(400).json({ success: false, message: err.errors[0]?.message }); return }
if (err instanceof Error) { res.status(500).json({ success: false, message: "Internal server error" }); return }
if (typeof err === "string") { res.status(400).json({ success: false, message: err }); return }
res.status(500).json({ success: false, message: "Something went wrong" })
}
app.use(errorMiddleware)
Custom AppError class is worth adding when you have a service layer needing domain-specific errors with status codes. Without a service layer, it's mostly ceremony.
Never console.log in production. Use Winston (reliable, battle-tested) or Pino (fastest).
logger.info("User created", { userId, email })
logger.error("Payment failed", { error, userId, amount })
logger.http(`${req.method} ${req.url} ${res.statusCode} ${latency}ms`)
Log: request method/url/status, user ID, latency, error stacks. Never log: passwords, tokens, PII in plaintext.
Cron jobs (node-cron or cron) are sufficient for: periodic reports, notifications, currency rate syncs, cleanup tasks.
new CronJob("0 9 * * *", notifyFinanceTeam).start()
BullMQ + Redis is worth adding when you need: job queuing with retries and dead letter queues, distributed workers, job priority/rate limiting, or real-time progress tracking.
Don't start with BullMQ. Upgrade to it when cron limitations become real problems.
Single config module. Never scatter process.env throughout the codebase:
// config.ts — validate and export once
const config = {
MONGO_URI: process.env.MONGO_URI!,
JWT_SECRET: process.env.JWT_SECRET!,
PORT: process.env.PORT || 8888,
AWS: {
BUCKET: process.env.AWS_BUCKET_NAME!,
REGION: process.env.AWS_REGION!,
},
}
export default config
req.body in controllersconsole.logPromise.all for parallel async operations/api/v1)Every request should follow this chain:
Request
↓ Global middleware (cors, json, cookieParser)
↓ Request logger (method, url, latency)
↓ Auth middleware
↓ Validation middleware (Zod)
↓ Controller
↓ Model / Service
↓ Response
↓ Error middleware (if next(err) called)
Break this chain → the codebase becomes unpredictable.
Routes only: define endpoints, attach middleware, call controller.
// ✅ Clean route
router.post("/users",
authMiddleware,
validate(createUserSchema),
userController.create,
)
// ❌ Never this
router.post("/users", async (req, res) => {
const user = await User.create(req.body) // validation? auth? error handling? 🫠
res.json(user)
})
const { payload } = await jwtVerify(token, secret) // use jose, not jsonwebtoken
req.user = payload
next()
Prefer jose over jsonwebtoken — ES module native, actively maintained, better TypeScript types.
Use HttpOnly cookies for JWT (not localStorage). Pair with short-lived access tokens + long-lived refresh tokens.
Pick a shape and stick to it across every endpoint:
{ "success": true, "data": { } }
{ "success": false, "message": "...", "status": 400 }
Inconsistent response shapes are a common pain point for frontend teams. A simple response utility function eliminates this:
// responseUtil.ts
export const ok = (res: Response, data: unknown) => res.json({ success: true, data })
export const fail = (res: Response, message: string, status = 400) =>
res.status(status).json({ success: false, message })
app.use(helmet()) // HTTP security headers
app.use(cors({ origin: whitelist, credentials: true }))
app.use(mongoSanitize()) // prevent NoSQL injection
router.post("/login", loginLimiter, controller) // rate limit auth routes
Bcrypt with salt rounds ≥ 12. Never store secrets in code.
Always prefix routes: /api/v1/resource. Non-negotiable from day one. Without it: breaking changes crash mobile apps, third-party clients, and integrated systems.
Don't write separate interfaces for Mongoose models. Let the schema be the source of truth:
const userSchema = new mongoose.Schema({
email: { type: String, unique: true, lowercase: true, required: true },
permissions: { type: [String], default: [] },
}, { timestamps: true, minimize: true })
export type User = InferSchemaType<typeof userSchema>
export default mongoose.model<User>("user", userSchema)
This keeps types automatically in sync with schema. No drift.
{
timestamps: true, // createdAt + updatedAt always
minimize: true, // strip empty objects (saves storage)
}
Index every field you query or sort by:
companyId: { type: ObjectId, index: true } // foreign keys
email: { type: String, unique: true } // unique constraint = index
status: { type: String, index: true } // frequently filtered
// Compound index for pagination and dashboard queries
userSchema.index({ companyId: 1, createdAt: -1 })
Don't over-index. Each index increases write time and uses RAM. Only index fields that appear in find(), sort(), or aggregation $match.
populate().populate() is a hidden N+1 query. Avoid it for list endpoints. Use aggregation:
UserModel.aggregate([
{ $match: { companyId: id } }, // filter first — always
{ $project: { name: 1, email: 1 } }, // reduce payload early
{ $lookup: { from: "companies", localField: "companyId", foreignField: "_id", as: "company" } },
{ $addFields: { company: { $arrayElemAt: ["$company", 0] } } },
])
populate() is acceptable on detail/single-document pages where N+1 is just 1 extra query.
lean() for Read Endpoints// ✅ 30–50% faster, returns plain JSON (not Mongoose Document instances)
const users = await UserModel.find({ companyId }).lean()
Skip lean() only when you need to call .save(), use virtuals, or trigger Mongoose middleware.
Promise.all for Parallel Operations// ✅ Parallel — total time = slowest operation
await Promise.all([save(), sendEmail(), logActivity()])
// ❌ Sequential — total time = sum of all
await save()
await sendEmail()
await logActivity()
Offset pagination (skip()) — fine for small datasets:
Model.find({}).skip(page * limit).limit(limit)
Cursor pagination — use when data grows large:
Model.find({ createdAt: { $lt: lastSeen } }).limit(10).sort({ createdAt: -1 })
Cursor pagination scales infinitely. skip(50000) makes Mongo scan 50k documents first.
Never hard-delete user-facing data in production:
// Schema
deletedAt: { type: Date, default: null }
// All queries include:
Model.find({ deletedAt: null })
Use sessions for multi-document operations that must be atomic:
const session = await mongoose.startSession()
session.startTransaction()
// debit + credit + log — all or nothing
await session.commitTransaction()
Required for: payments, wallet transfers, inventory deductions. Don't rely on document atomicity for operations spanning multiple collections.
InferSchemaType for model typestimestamps: true and minimize: true on every schemapopulate() on lists)lean() on all read-only endpointsPromise.all for independent DB operationsdeletedAt) for user-facing data$match as early as possible in every aggregation{
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "bundler"
}
}
strict: true is non-negotiable. Without it, TypeScript gives false confidence.verbatimModuleSyntax: true enforces import type for type-only imports, preventing runtime bundle pollution.// ❌ Redundant annotation
const name: string = "John"
// ✅ Inferred
const name = "John"
const items: string[] = [] // annotation useful here — empty array needs a hint
Always annotate: function parameters, exported interfaces, API return types, DTO shapes.
interface vs type// interface — object shapes, extendable domain models
interface User { _id: string; email: string; permissions: string[] }
// type — unions, tuples, mapped types, utility composition
type Role = "admin" | "user" | "viewer"
type UserOrNull = User | null
any — Use unknown// ❌ Kills all type safety downstream
function process(data: any) { }
// ✅ Safe, narrow before use
function process(data: unknown) {
if (typeof data === "string") return data.toUpperCase()
}
import type is RequiredWith verbatimModuleSyntax: true, type-only imports must use import type:
import type { Request, Response, NextFunction } from "express"
import type { User } from "@/models/User.model"
Configure @/* to map to ./src/* in both tsconfig.json and your bundler:
// Instead of:
import config from "../../../configs/config"
// Use:
import config from "@/configs/config"
// ❌ Runtime crash waiting to happen
user.name.length
// ✅ Safe access
user?.name?.length ?? 0
With strictNullChecks (part of strict: true), TypeScript catches most of these at compile time.
// Reusable across users, orders, products
async function fetchData<T>(url: string): Promise<T> {
const res = await api.get(url)
return res.data
}
strict: true alwaysverbatimModuleSyntax: true → enforce import typenoUncheckedIndexedAccess — safe array/object access@/*)interface for object shapes, type for unionsInferSchemaType for Mongoose model typesany — use unknown + narrowing| Tool | Purpose |
|---|---|
| ESLint | Catch bugs and enforce patterns |
| Prettier | Formatting (no style debates) |
| Husky | Git hooks |
| lint-staged | Only lint/format changed files |
| commitlint | Enforce conventional commits (feat:, fix:) |
| TypeScript strict | Type safety |
Run Prettier and ESLint on every commit via Husky. No exceptions.
Login (OAuth2/OIDC or username+password)
→ Issue Access Token (short-lived, jose JWT) + Refresh Token (long-lived)
→ Set HttpOnly cookies (__Secure- prefix in production)
Per request:
→ Auth middleware reads cookie-first, Authorization header as fallback
→ Verify with jose jwtVerify
→ Attach req.user
Token refresh:
→ POST /auth/refresh → issue new access token
Use jose (not jsonwebtoken) — native ES modules, better TypeScript support, actively maintained.
| Practice | Add When | Skip If | |---|---|---| | Service layer | Business logic shared across 3+ controllers | Simple CRUD, single-owner backend | | Repository layer | Swapping databases, heavy unit testing | Mongo-only app, no test mocking needed | | BullMQ job queues | Multi-worker, retries, rate limiting needed | Periodic jobs → use cron instead | | DI framework | Enterprise scale, 10+ services | Most MERN startups | | Feature-based folders | 50+ features, multi-team ownership | Small team, single codebase owner | | Zustand | Real client-only global state needed | Query cache covers auth + server state | | Redis caching | High-traffic read-heavy endpoints | API response times already acceptable | | Cursor pagination | Lists grow beyond thousands of records | Small datasets with offset pagination | | Virtualized lists | Lists exceed 200+ visible items | Normal-size data tables | | Custom AppError class | Service layer throwing domain errors | Thin controllers with no service layer |
development
A strict 2026 MERN stack engineering playbook with comprehensive code examples for React, Node, Express, and MongoDB.
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.