Scaffold a production-ready Express 5.x API with TypeScript, layered architecture, Zod validation, and robust error handling.
59
62%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No known issues
Fix and improve this skill with Tessl
tessl review fix ./backend-node/expressjs-project-starter/SKILL.mdScaffold 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 templateroutes/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 --noEmitexpress-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.181fcbc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.