CtrlK
BlogDocsLog inGet started
Tessl Logo

expressjs-project-starter

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-starter
What are skills?

74

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Express.js Project Starter

Scaffold a production-ready Express 5.x API with TypeScript, layered architecture, Zod validation, and robust error handling.

Prerequisites

  • Node.js >= 20.x
  • npm >= 10.x (or pnpm >= 9.x)
  • TypeScript >= 5.3

Scaffold Command

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}

Project Structure

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

Key Conventions

  • Layered architecture: routes -> controllers -> services. No skipping layers.
  • Controllers never import database clients — they call services.
  • Every async route handler is wrapped to forward rejections to the error middleware.
  • Validation happens at the boundary — in middleware, before the controller runs.
  • One router file per resource — mounted in routes/index.ts.
  • Environment config is parsed and validated once at startup via Zod. Never read process.env directly in service code.
  • No any — strict TypeScript throughout.

Essential Patterns

App Factory — app.ts

import 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;
}

Server Entry — index.ts

import { 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}]`);
});

Typed Environment Config — config/env.ts

import { 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>;

Custom API Error — utils/api-error.ts

export 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);
  }
}

Async Handler Wrapper — utils/async-handler.ts

import { 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);
  };
}

Global Error Handler — middleware/error-handler.ts

import { 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 } : {}),
  });
}

404 Handler — middleware/not-found.ts

import { Request, Response } from 'express';

export function notFoundHandler(_req: Request, res: Response) {
  res.status(404).json({ error: 'Route not found' });
}

Zod Validation Middleware — middleware/validate.ts

import { 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 — routes/users.routes.ts

import { 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,
);

Route Aggregator — routes/index.ts

import { 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 and Schemas — types/user.types.ts

import { 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>;

Controller — controllers/users.controller.ts

import { 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);
});

Service — services/users.service.ts

import 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;
}

First Steps After Scaffold

  1. Copy .env.example to .env and fill in values
  2. Install dependencies: pnpm install
  3. Start dev server: pnpm tsx watch src/index.ts
  4. Test the API: curl http://localhost:3000/api/health

Common Commands

# 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

Integration Notes

  • Database: Pair with Prisma or Drizzle ORM. Import the client in service files only, never in controllers.
  • Auth: Use express-jwt or a custom middleware that verifies JWTs and attaches the user to req. Define a typed AuthRequest interface extending Request.
  • Rate Limiting: express-rate-limit for API rate limiting per IP or per user.
  • Logging: Replace morgan with pino-http for structured JSON logging in production.
  • Testing: Use supertest to test routes against the app factory. Mock services for controller tests.
  • Docker: Multi-stage build. Copy only dist/ and package.json into the final stage.
  • OpenAPI: Use swagger-jsdoc + swagger-ui-express if Swagger docs are needed. Alternatively, generate from Zod schemas with @asteasolutions/zod-to-openapi.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

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.