Scaffold a production-ready Express 5.x API with TypeScript, layered architecture, Zod validation, and robust error handling.
Install with Tessl CLI
npx tessl i github:achreftlili/deep-dev-skills --skill expressjs-project-starter74
Does it follow best practices?
If you maintain this skill, you can automatically optimize it using the tessl CLI to improve its score:
npx tessl skill review --optimize ./path/to/skillValidation for skill structure
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 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.