backend-node/expressjs-project-starter/SKILL.md
Scaffold a production-ready Express 5.x API with TypeScript, layered architecture, Zod validation, and robust error handling.
npx skillsauth add achreftlili/deep-dev-skills expressjs-project-starterInstall 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.
Scaffold a production-ready Express 5.x API with TypeScript, layered architecture, Zod validation, and robust error handling.
mkdir <project-name> && cd <project-name>
pnpm init
pnpm add express@5 dotenv helmet cors morgan zod http-status-codes
pnpm add -D typescript @types/node @types/express @types/cors @types/morgan tsx tsup nodemon
# Generate tsconfig
npx tsc --init --strict --target ES2022 --module NodeNext \
--moduleResolution NodeNext --outDir dist --rootDir src \
--esModuleInterop --skipLibCheck --forceConsistentCasingInFileNames
mkdir -p src/{routes,controllers,services,middleware,config,types,utils}
src/
index.ts # Entry point — starts the HTTP server
app.ts # Express app factory — mounts middleware and routes
config/
env.ts # Typed environment variables via Zod
routes/
index.ts # Root router — aggregates all feature routers
users.routes.ts
health.routes.ts
controllers/
users.controller.ts # Thin — parses req, calls service, sends res
services/
users.service.ts # Business logic — no req/res awareness
middleware/
error-handler.ts # Global async error handler
validate.ts # Zod validation middleware factory
not-found.ts # 404 catch-all
types/
user.types.ts # Shared type definitions
utils/
async-handler.ts # Wraps async route handlers to catch rejections
api-error.ts # Custom error class with status codes
.env.example # Required env vars template
routes/index.ts.process.env directly in service code.any — strict TypeScript throughout.app.tsimport express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import { router } from './routes/index.js';
import { errorHandler } from './middleware/error-handler.js';
import { notFoundHandler } from './middleware/not-found.js';
export function createApp() {
const app = express();
// Security and parsing
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '1mb' }));
app.use(morgan('short'));
// Routes
app.use('/api', router);
// Error handling — order matters: 404 first, then global handler
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
index.tsimport { createApp } from './app.js';
import { env } from './config/env.js';
const app = createApp();
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT} [${env.NODE_ENV}]`);
});
config/env.tsimport { z } from 'zod';
import 'dotenv/config';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const env = envSchema.parse(process.env);
export type Env = z.infer<typeof envSchema>;
utils/api-error.tsexport class ApiError extends Error {
constructor(
public readonly statusCode: number,
message: string,
public readonly details?: unknown,
) {
super(message);
this.name = 'ApiError';
}
static badRequest(message: string, details?: unknown) {
return new ApiError(400, message, details);
}
static notFound(message = 'Resource not found') {
return new ApiError(404, message);
}
static unauthorized(message = 'Unauthorized') {
return new ApiError(401, message);
}
}
utils/async-handler.tsimport { Request, Response, NextFunction, RequestHandler } from 'express';
// Express 5 natively handles async errors, but this provides an explicit safety net
// and works if you're on Express 4 as well.
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
middleware/error-handler.tsimport { Request, Response, NextFunction } from 'express';
import { ApiError } from '../utils/api-error.js';
import { ZodError } from 'zod';
import { env } from '../config/env.js';
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
// Zod validation errors
if (err instanceof ZodError) {
res.status(400).json({
error: 'Validation failed',
details: err.flatten().fieldErrors,
});
return;
}
// Known operational errors
if (err instanceof ApiError) {
res.status(err.statusCode).json({
error: err.message,
...(err.details ? { details: err.details } : {}),
});
return;
}
// Unknown errors — log and return generic message
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
...(env.NODE_ENV === 'development' ? { stack: err.stack } : {}),
});
}
middleware/not-found.tsimport { Request, Response } from 'express';
export function notFoundHandler(_req: Request, res: Response) {
res.status(404).json({ error: 'Route not found' });
}
middleware/validate.tsimport { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
export function validate(schema: {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}) {
return (req: Request, _res: Response, next: NextFunction) => {
if (schema.body) req.body = schema.body.parse(req.body);
if (schema.query) req.query = schema.query.parse(req.query) as any;
if (schema.params) req.params = schema.params.parse(req.params) as any;
next();
};
}
routes/users.routes.tsimport { Router } from 'express';
import { validate } from '../middleware/validate.js';
import * as usersController from '../controllers/users.controller.js';
import { createUserSchema, userParamsSchema } from '../types/user.types.js';
export const usersRouter = Router();
usersRouter.post(
'/',
validate({ body: createUserSchema }),
usersController.create,
);
usersRouter.get(
'/:id',
validate({ params: userParamsSchema }),
usersController.findOne,
);
routes/index.tsimport { Router } from 'express';
import { usersRouter } from './users.routes.js';
export const router = Router();
router.use('/users', usersRouter);
router.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
types/user.types.tsimport { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1),
});
export const userParamsSchema = z.object({
id: z.string().uuid(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
controllers/users.controller.tsimport { Request, Response } from 'express';
import { asyncHandler } from '../utils/async-handler.js';
import * as usersService from '../services/users.service.js';
import { CreateUserInput } from '../types/user.types.js';
export const create = asyncHandler(async (req: Request, res: Response) => {
const input: CreateUserInput = req.body;
const user = await usersService.create(input);
res.status(201).json(user);
});
export const findOne = asyncHandler(async (req: Request, res: Response) => {
const user = await usersService.findById(req.params.id);
res.json(user);
});
services/users.service.tsimport crypto from 'node:crypto';
import { ApiError } from '../utils/api-error.js';
import { CreateUserInput } from '../types/user.types.js';
// Replace with real database client (Prisma, Drizzle, etc.)
const users = new Map<string, { id: string; email: string; name: string }>();
export async function create(input: CreateUserInput) {
const id = crypto.randomUUID();
const user = { id, email: input.email, name: input.name };
users.set(id, user);
return user;
}
export async function findById(id: string) {
const user = users.get(id);
if (!user) throw ApiError.notFound(`User ${id} not found`);
return user;
}
.env.example to .env and fill in valuespnpm installpnpm tsx watch src/index.tscurl http://localhost:3000/api/health# Development with hot reload
pnpm tsx watch src/index.ts
# Build
pnpm tsup src/index.ts --format esm --dts
# Production
node dist/index.js
# Lint (if eslint configured)
pnpm eslint src/
# Test (vitest or jest)
pnpm vitest run
# Type check without emitting
pnpm tsc --noEmit
express-jwt or a custom middleware that verifies JWTs and attaches the user to req. Define a typed AuthRequest interface extending Request.express-rate-limit for API rate limiting per IP or per user.morgan with pino-http for structured JSON logging in production.supertest to test routes against the app factory. Mock services for controller tests.dist/ and package.json into the final stage.swagger-jsdoc + swagger-ui-express if Swagger docs are needed. Alternatively, generate from Zod schemas with @asteasolutions/zod-to-openapi.testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.