This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
TypeScript Backend Toolkit is a production-ready Express.js backend framework with a plugin-based architecture. It emphasizes type safety, auto-generated documentation, and developer productivity through an Artisan-like CLI.
pnpm dev # Start dev server with hot reload + email preview
pnpm start:dev # Start dev server only
pnpm build # Compile TypeScript to dist/
pnpm start:prod # Run production build
pnpm typecheck # Type check without building
pnpm lint # Run ESLint
pnpm lint:fix # Auto-fix linting issuespnpm email:dev # Preview email templates (http://localhost:3001)pnpm tbk generate:module <name> [--path /api/v1] # Generate complete module
pnpm tbk generate:plugin <name> # Generate plugin
pnpm tbk generate:middleware <name> # Generate middleware
pnpm tbk generate:factory <name> # Generate factory for model
pnpm tbk seed # Run database seederspnpm tbk docs:openapi # Generate OpenAPI spec (public/openapi.yml)
pnpm tbk docs:sdk # Generate TypeScript SDK from OpenAPI spec
# Or use convenience aliases:
pnpm openapi # Alias for tbk docs:openapi
pnpm gen-sdk # Alias for tbk docs:sdkVisit http://localhost:3000/docs for live Swagger UI.
All routes MUST use MagicRouter - it auto-generates OpenAPI documentation from Zod schemas and provides typed responses.
import MagicRouter from '@/plugins/magic/router';
import { canAccess } from '@/middlewares/can-access';
const router = new MagicRouter('/api/users');
// Every route needs: path, config object, ...handlers
router.post(
'/create',
{
requestType: { body: createUserSchema },
responses: { 201: createUserResponseSchema },
},
canAccess(), // Middleware (optional)
handleCreate, // Controller (last handler)
);
export default router.getRouter(); // MUST call .getRouter()Key points:
- Second argument is ALWAYS a config object (use
{}if no validation) - Last handler is the controller, preceding ones are middleware
- Never use Express's
app.get()orrouter.get()directly - Always define response schemas in
*.schema.tsfiles usingR.success()/R.paginated()
Every module follows this exact structure:
src/modules/<module-name>/
├── <module-name>.dto.ts # Zod schemas and TypeScript types
├── <module-name>.model.ts # Mongoose model
├── <module-name>.schema.ts # Request/response validation schemas
├── <module-name>.services.ts # Business logic and data access
├── <module-name>.controller.ts # HTTP request handlers
└── <module-name>.router.ts # MagicRouter route definitions
Responsibilities:
- DTOs: Define input/output Zod schemas, export inferred types
- Models: Mongoose schemas, no business logic
- Schemas: Request validation (body/params/query) + response schemas with R builders
- Services: Framework-agnostic business logic, database operations
- Controllers: Thin HTTP handlers using
ResponseExtended<T>for type-safe responses - Routers: MagicRouter configuration with middleware
Always define response schemas in *.schema.ts files:
// In module.schema.ts
import { R } from '@/plugins/magic/response.builders';
import { itemOutSchema } from './module.dto';
export const createItemResponseSchema = R.success(itemOutSchema);
export const getItemsResponseSchema = R.paginated(itemOutSchema);
export type CreateItemResponseSchema = z.infer<typeof createItemResponseSchema>;
export type GetItemsResponseSchema = z.infer<typeof getItemsResponseSchema>;
// In module.router.ts
router.post('/', {
requestType: { body: createItemSchema },
responses: { 201: createItemResponseSchema },
}, canAccess(), handleCreate);
// In module.controller.ts
export const handleCreate = async (
req: Request<unknown, unknown, CreateItemSchemaType>,
res: ResponseExtended<CreateItemResponseSchema>,
) => {
const item = await createItem(req.body);
return res.created?.({
success: true,
message: 'Item created',
data: item,
});
};Response builders:
R.success(schema)- Standard envelope:{ success, message?, data? }R.paginated(itemSchema)- List with pagination:{ success, message?, data: { items, paginator } }R.noContent()- 204 empty responseR.error()- Error envelopeR.raw(schema)- Non-envelope response (healthchecks, etc.)
import type { Request } from 'express';
import type { ResponseExtended } from '@/types';
import type { CreateUserSchemaType, CreateUserResponseSchema } from './user.schema';
export const handleCreate = async (
req: Request<ParamsType, unknown, BodyType, QueryType>,
res: ResponseExtended<ResponseSchemaType>,
) => {
// Request already validated by Zod middleware
const data = req.body;
const userId = req.user?.sub; // JWT payload from canAccess()
const result = await createUser(data);
// Use typed response helpers
return res.created?.({
success: true,
message: 'User created',
data: result,
});
};Available response helpers: res.ok?.(), res.created?.(), res.noContent?.(), res.notFound?.(), res.badRequest?.(), res.forbidden?.()
Plugins are registered in src/app/app.ts via the createApp() function. Each plugin implements the ToolkitPlugin interface:
export interface ToolkitPlugin {
name: string;
priority?: number; // Higher priority = registered first
register(context: AppContext): Promise<void | string[]> | void | string[];
onShutdown?: () => Promise<void>;
}Built-in plugins:
- logger - Pino logger with pretty printing, HTTP request logging, child logger factory
- observability - Metrics (Prometheus), request IDs, health checks
- security - Helmet, CORS, rate limiting
- cache - Redis/memory caching with middleware
- magic - MagicRouter, OpenAPI generation, response validation
- lifecycle - Graceful shutdown handling
- auth - JWT extraction and session management
- admin - Django-style admin panel (
/admin) - realtime - Socket.IO with testing UI (
/realtime)
Plugins can return an array of URLs to display on startup.
Always use Zod for validation, never Mongoose validators:
import { z } from 'zod';
import validator from 'validator';
export const createUserSchema = z.object({
email: z.string({ required_error: 'Email is required' })
.email({ message: 'Invalid email' }),
password: z.string({ required_error: 'Password is required' })
.min(8)
.max(64),
userId: z.string()
.refine((val) => validator.isMongoId(val), 'Invalid MongoDB ID'),
});
export type CreateUserSchemaType = z.infer<typeof createUserSchema>;Key patterns:
- Use
validatorpackage for MongoDB IDs, NOT regex - Use
{ required_error: 'message' }for required fields - Query params need
.transform(Number)for numeric values - Export types with
z.infer<typeof schema>
Services are framework-agnostic and handle all business logic:
// Services return data or throw errors
export const findById = async (id: string) => {
const user = await UserModel.findById(id);
return user; // null if not found
};
export const create = async (data: CreateInput) => {
const exists = await UserModel.findOne({ email: data.email });
if (exists) {
const error = new Error('User already exists') as any;
error.statusCode = 400;
throw error;
}
const user = await UserModel.create(data);
logger.info('User created', { userId: user._id });
return user;
};Guidelines:
- Services throw errors with
statusCodeproperty for HTTP status codes - Controllers decide how to handle null returns
- Never import Express types (Request/Response) in services
- Use logger from
@/plugins/logger
The toolkit uses Formidable (not Multer) for file uploads:
// In schema file
import { zFile, zFiles, MIME_GROUPS } from '@/plugins/magic/zod-extend';
export const uploadSchema = z.object({
avatar: zFile({
maxSize: 5 * 1024 * 1024, // 5MB
allowedTypes: MIME_GROUPS.IMAGES,
}),
documents: zFiles({
maxSize: 2 * 1024 * 1024,
allowedTypes: MIME_GROUPS.DOCUMENTS,
}).optional(),
});
// In router
router.post('/upload', {
requestType: { body: uploadSchema },
contentType: 'multipart/form-data',
multipart: true,
}, canAccess(), handleUpload);
// In controller - files are in req.body, NOT req.file/req.files
const file = req.body.avatar; // Single file
const files = req.body.documents; // Multiple files (optional)
// Upload to S3/R2
import { uploadFile } from '@/lib/storage';
const { url } = await uploadFile({ file, key: `uploads/${file.originalFilename}` });JWT payload is available via req.user when using canAccess() middleware:
import { canAccess } from '@/middlewares/can-access';
// In router
router.get('/me', {}, canAccess(), handleGetCurrentUser);
// In controller
const userId = req.user?.sub; // User ID
const email = req.user?.email; // Email (optional)
const role = req.user?.role; // Role enum
const username = req.user?.username; // Username
// JWT utils
import { signToken, verifyToken } from '@/utils/jwt.utils';
const token = await signToken({ sub: userId, email, username, role });The toolkit includes a flexible session system:
- Session driver: MongoDB or Redis (configured via
SESSION_DRIVERenv var) - Session rotation, idle/absolute TTLs, max sessions per user
- Automatic cleanup with configurable cron schedule
- Session debugging mode
Sessions are managed separately from JWT tokens. See src/modules/auth/session/ for implementation.
All config in src/config/env.ts validated with Zod. Time values are in milliseconds.
Key variables:
NODE_ENV-development|production|testSESSION_DRIVER-mongo|redisSTORAGE_PROVIDER-s3|r2|localCACHE_PROVIDER-redis|memoryRESPONSE_VALIDATION-strict|warn|off(validates responses against schemas)
Always use import config from '@/config/env' - never access process.env directly.
ALWAYS use the CLI:
pnpm tbk generate:module <name>This scaffolds all 6 files following project patterns. After generation:
- Customize the model with your fields and indexes
- Update DTOs with Zod input/output schemas
- Define request/response validation in schema file
- Implement service functions (business logic)
- Write thin controllers that delegate to services
- Configure routes with MagicRouter (auth, validation)
- Register router in
src/routes/routes.ts
Never create modules manually - the CLI ensures consistency and catches common mistakes.
TypeScript paths configured in tsconfig.json:
@/*resolves to./src/*
Example: import { logger } from '@/plugins/logger'
CRITICAL: Always use validator.isMongoId(), NEVER regex:
// ✅ CORRECT
import validator from 'validator';
z.string().refine((val) => validator.isMongoId(val), 'Invalid ID')
// ❌ WRONG - DO NOT USE REGEX
z.string().regex(/^[a-f\d]{24}$/i)Controllers can handle errors two ways:
Option 1: Typed error responses (RECOMMENDED)
if (!item) {
return res.notFound?.({ success: false, message: 'Not found' });
}Option 2: Let global error handler catch thrown errors
const error = new Error('Forbidden') as any;
error.statusCode = 403;
throw error;Controllers don't need try-catch blocks - global error handler in src/middlewares/error-handler.ts catches everything.
Use BullMQ for background processing:
import { emailQueue } from '@/queues/email.queue';
await emailQueue.add('sendWelcome', { email, name }, {
delay: 5000, // Optional delay in ms
attempts: 3, // Retry attempts
});Queue dashboard available at /queues (protected by QUEUE_AUTH_ENABLED).
Django-style auto-generated admin UI at /admin (protected by ADMIN_AUTH_ENABLED).
Models are introspected and rendered with full CRUD. Configure admin access in src/plugins/admin/registry.ts.
src/main.ts- Application entry point, bootstraps serversrc/app/app.ts- Plugin registration and app initializationsrc/routes/routes.ts- Main API router registrationsrc/config/env.ts- Environment configuration with Zod validationsrc/lib/database.ts- MongoDB connection managementsrc/lib/cache.ts- Redis/memory cache clientsrc/lib/storage.ts- S3/R2/local file storagesrc/lib/queue.ts- BullMQ queue configurationsrc/email/email.service.ts- Email sending (SMTP/Resend/Mailgun)src/plugins/logger/index.ts- Logger plugin with Pino, HTTP logging, child logger factorysrc/plugins/magic/router.ts- MagicRouter implementationsrc/plugins/magic/response.builders.ts- Response schema builders (R.success, etc.)src/middlewares/can-access.ts- JWT authentication middlewaresrc/middlewares/error-handler.ts- Global error handlersrc/utils/- Pure utility functions (JWT, passwords, pagination, etc.)
- ❌ Using plain Express routing instead of MagicRouter
- ❌ Forgetting to call
.getRouter()when exporting router - ❌ Not providing config object as 2nd argument to MagicRouter methods
- ❌ Defining response schemas inline instead of in
*.schema.ts - ❌ Using
res.status().json()instead ofResponseExtendedhelpers - ❌ Using regex for MongoDB IDs instead of
validator.isMongoId() - ❌ Importing Express types in service files
- ❌ Putting business logic in controllers instead of services
- ❌ Creating modules manually instead of using
pnpm tbk generate:module - ❌ Accessing
process.envdirectly instead of usingconfig - ❌ Using
req.file/req.filesinstead ofreq.bodyfor Formidable uploads - ❌ Forgetting to register new routers in
src/routes/routes.ts
ALWAYS use pnpm - never npm or yarn. The project uses pnpm.