toolchains/typescript/frameworks/nodejs-backend/SKILL.md
Node.js backend development with TypeScript, Express/Fastify servers, routing, middleware, and database integration
npx skillsauth add bobmatnyc/claude-mpm-skills nodejs-backend-typescriptInstall 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.
tsconfig.json (strict mode recommended):
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.json scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest"
}
}
npm install -D typescript @types/node tsx vitest
npm install -D @types/express # or @types/node (Fastify has built-in types)
src/server.ts:
import express, { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Type-safe request handlers
interface TypedRequest<T> extends Request {
body: T;
}
// Routes
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Start server
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
src/routes/users.ts:
import { Router } from 'express';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation';
const router = Router();
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
age: z.number().int().positive().optional(),
});
router.post(
'/users',
validateRequest(createUserSchema),
async (req, res, next) => {
try {
const userData = req.body; // Type-safe after validation
// Database insert logic
res.status(201).json({ id: 1, ...userData });
} catch (error) {
next(error);
}
}
);
export default router;
src/middleware/validation.ts:
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
export const validateRequest = (schema: ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'Validation failed',
details: error.errors,
});
} else {
next(error);
}
}
};
};
src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
interface JwtPayload {
userId: string;
email: string;
}
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
src/middleware/errorHandler.ts:
import { Request, Response, NextFunction } from 'express';
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public isOperational = true
) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
}
}
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
}
console.error('Unexpected error:', err);
res.status(500).json({
error: 'Internal server error',
...(process.env.NODE_ENV === 'development' && {
message: err.message,
stack: err.stack,
}),
});
};
src/server.ts:
import Fastify from 'fastify';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
const fastify = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
},
}).withTypeProvider<TypeBoxTypeProvider>();
// Type-safe route with schema validation
fastify.route({
method: 'POST',
url: '/users',
schema: {
body: Type.Object({
email: Type.String({ format: 'email' }),
name: Type.String({ minLength: 2 }),
age: Type.Optional(Type.Integer({ minimum: 0 })),
}),
response: {
201: Type.Object({
id: Type.Number(),
email: Type.String(),
name: Type.String(),
}),
},
},
handler: async (request, reply) => {
const { email, name, age } = request.body;
// Auto-typed and validated
return reply.status(201).send({ id: 1, email, name });
},
});
const start = async () => {
try {
await fastify.listen({ port: 3000, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
src/plugins/database.ts:
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
declare module 'fastify' {
interface FastifyInstance {
db: ReturnType<typeof drizzle>;
}
}
const databasePlugin: FastifyPluginAsync = async (fastify) => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const db = drizzle(pool);
fastify.decorate('db', db);
fastify.addHook('onClose', async () => {
await pool.end();
});
};
export default fp(databasePlugin);
src/hooks/auth.ts:
import { FastifyRequest, FastifyReply } from 'fastify';
import jwt from 'jsonwebtoken';
declare module 'fastify' {
interface FastifyRequest {
user?: {
userId: string;
email: string;
};
}
}
export const authHook = async (
request: FastifyRequest,
reply: FastifyReply
) => {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
return reply.status(401).send({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
email: string;
};
request.user = decoded;
} catch (error) {
return reply.status(401).send({ error: 'Invalid token' });
}
};
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
profile: z.object({
firstName: z.string(),
lastName: z.string(),
age: z.number().int().positive(),
}),
tags: z.array(z.string()).optional(),
});
type CreateUserInput = z.infer<typeof userSchema>;
router.post('/users', async (req, res) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.format(),
});
}
const user: CreateUserInput = result.data;
// Type-safe user object
});
import { Type, Static } from '@sinclair/typebox';
const UserSchema = Type.Object({
email: Type.String({ format: 'email' }),
password: Type.String({ minLength: 8 }),
profile: Type.Object({
firstName: Type.String(),
lastName: Type.String(),
age: Type.Integer({ minimum: 0 }),
}),
tags: Type.Optional(Type.Array(Type.String())),
});
type User = Static<typeof UserSchema>;
fastify.post('/users', {
schema: { body: UserSchema },
handler: async (request, reply) => {
const user: User = request.body; // Auto-validated
return { id: 1, ...user };
},
});
src/services/auth.ts:
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
interface TokenPayload {
userId: string;
email: string;
}
export class AuthService {
private static JWT_SECRET = process.env.JWT_SECRET!;
private static JWT_EXPIRES_IN = '7d';
static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
static async comparePassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
static generateToken(payload: TokenPayload): string {
return jwt.sign(payload, this.JWT_SECRET, {
expiresIn: this.JWT_EXPIRES_IN,
});
}
static verifyToken(token: string): TokenPayload {
return jwt.verify(token, this.JWT_SECRET) as TokenPayload;
}
}
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({
url: process.env.REDIS_URL,
});
redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
})
);
declare module 'express-session' {
interface SessionData {
userId: string;
}
}
src/db/schema.ts:
import { pgTable, serial, varchar, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
src/db/client.ts:
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
src/repositories/userRepository.ts:
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import { users, NewUser } from '../db/schema';
export class UserRepository {
static async create(data: NewUser) {
const [user] = await db.insert(users).values(data).returning();
return user;
}
static async findByEmail(email: string) {
return db.query.users.findFirst({
where: eq(users.email, email),
});
}
static async findById(id: number) {
return db.query.users.findFirst({
where: eq(users.id, id),
});
}
static async list(limit = 10, offset = 0) {
return db.query.users.findMany({
limit,
offset,
columns: {
passwordHash: false, // Exclude sensitive fields
},
});
}
}
prisma/schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
posts Post[]
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int @map("author_id")
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
@@map("posts")
}
src/services/userService.ts:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class UserService {
static async createUser(data: { email: string; name: string; password: string }) {
const passwordHash = await AuthService.hashPassword(data.password);
return prisma.user.create({
data: {
email: data.email,
name: data.name,
passwordHash,
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
}
static async getUserWithPosts(userId: number) {
return prisma.user.findUnique({
where: { id: userId },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
},
},
});
}
}
Pagination:
import { z } from 'zod';
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
router.get('/users', async (req, res) => {
const { page, limit } = paginationSchema.parse(req.query);
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
UserRepository.list(limit, offset),
UserRepository.count(),
]);
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
});
Filtering and Sorting:
const filterSchema = z.object({
status: z.enum(['active', 'inactive']).optional(),
search: z.string().optional(),
sortBy: z.enum(['createdAt', 'name', 'email']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
router.get('/users', async (req, res) => {
const filters = filterSchema.parse(req.query);
const users = await db.query.users.findMany({
where: and(
filters.status && eq(users.status, filters.status),
filters.search && ilike(users.name, `%${filters.search}%`)
),
orderBy: [
filters.sortOrder === 'asc'
? asc(users[filters.sortBy])
: desc(users[filters.sortBy]),
],
});
res.json({ data: users });
});
interface ErrorResponse {
error: string;
message: string;
statusCode: number;
details?: unknown;
timestamp: string;
path: string;
}
export const formatError = (
err: AppError,
req: Request
): ErrorResponse => ({
error: err.name,
message: err.message,
statusCode: err.statusCode,
...(err.details && { details: err.details }),
timestamp: new Date().toISOString(),
path: req.path,
});
src/config/env.ts:
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer<typeof envSchema>;
export const env = envSchema.parse(process.env);
Usage:
import { env } from './config/env';
const port = env.PORT; // Type-safe, validated
vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});
src/tests/users.test.ts:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../server';
import { db } from '../db/client';
describe('User API', () => {
beforeAll(async () => {
// Setup test database
await db.delete(users);
});
afterAll(async () => {
// Cleanup
});
it('should create a new user', async () => {
const response = await request(app)
.post('/users')
.send({
email: '[email protected]',
name: 'Test User',
password: 'password123',
})
.expect(201);
expect(response.body).toMatchObject({
email: '[email protected]',
name: 'Test User',
});
expect(response.body).toHaveProperty('id');
expect(response.body).not.toHaveProperty('passwordHash');
});
it('should return 400 for invalid email', async () => {
const response = await request(app)
.post('/users')
.send({
email: 'invalid-email',
name: 'Test User',
password: 'password123',
})
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
src/services/auth.test.ts:
import { describe, it, expect } from 'vitest';
import { AuthService } from './auth';
describe('AuthService', () => {
it('should hash password correctly', async () => {
const password = 'mySecurePassword123';
const hash = await AuthService.hashPassword(password);
expect(hash).not.toBe(password);
expect(hash.length).toBeGreaterThan(50);
});
it('should verify password correctly', async () => {
const password = 'mySecurePassword123';
const hash = await AuthService.hashPassword(password);
const isValid = await AuthService.comparePassword(password, hash);
expect(isValid).toBe(true);
const isInvalid = await AuthService.comparePassword('wrongPassword', hash);
expect(isInvalid).toBe(false);
});
it('should generate valid JWT token', () => {
const token = AuthService.generateToken({
userId: '123',
email: '[email protected]',
});
expect(token).toBeTruthy();
const decoded = AuthService.verifyToken(token);
expect(decoded).toMatchObject({
userId: '123',
email: '[email protected]',
});
});
});
Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]
docker-compose.yml:
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
depends_on:
- db
- redis
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=mydb
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
ecosystem.config.js:
module.exports = {
apps: [{
name: 'api',
script: './dist/server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
}],
};
src/
├── server.ts # Entry point
├── config/
│ └── env.ts # Environment config
├── routes/
│ ├── index.ts # Route aggregator
│ ├── users.ts
│ └── posts.ts
├── middleware/
│ ├── auth.ts
│ ├── validation.ts
│ └── errorHandler.ts
├── services/
│ ├── auth.ts
│ └── user.ts
├── repositories/
│ └── userRepository.ts
├── db/
│ ├── client.ts
│ └── schema.ts
├── types/
│ └── index.ts
└── tests/
├── setup.ts
├── users.test.ts
└── auth.test.ts
Use Express when:
Use Fastify when:
development
Optimize web performance using Core Web Vitals, modern patterns (View Transitions, Speculation Rules), and framework-specific techniques
development
Best practices for documenting APIs and code interfaces, eliminating redundant documentation guidance per agent.
development
Comprehensive API design patterns covering REST, GraphQL, gRPC, versioning, authentication, and modern API best practices
development
Visual verification workflow for UI changes to accelerate code review and catch ...