Backend Master Skill
Unified decision framework for TypeScript backend development.
Stack: Node.js · TypeScript · tRPC/Express · Prisma · Zod · Vitest · Docker
Quick Decision Matrix
WHAT DO YOU NEED? │ ├─► API Layer │ ├─ Full-stack TypeScript app → tRPC [skill: backend-trpc] │ ├─ Need REST for external clients → tRPC + OpenAPI [skill: backend-trpc-openapi] │ └─ Pure Express API → Express + Zod │ ├─► Authentication │ ├─ Next.js App Router → Auth.js [skill: backend-auth-js] │ └─ Express/pure API → Passport.js [skill: backend-passport-js] │ ├─► Database │ └─ TypeScript + SQL → Prisma [skill: backend-prisma] │ ├─► Validation │ └─ Any input validation → Zod [skill: backend-zod] │ ├─► Observability │ └─ Structured logging → Pino [skill: backend-pino] │ ├─► Testing │ └─ Unit/integration tests → Vitest [skill: backend-vitest] │ └─► Deployment └─ Containerization → Docker [skill: docker-node]
- Project Setup Checklist
New tRPC + Prisma Project
Initialize
mkdir my-api && cd my-api npm init -y
Core dependencies
npm install @trpc/server zod @prisma/client pino npm install -D typescript @types/node prisma vitest
Initialize TypeScript
npx tsc --init
Initialize Prisma
npx prisma init
Recommended tsconfig.json
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "src", "declaration": true, "resolveJsonModule": true, "baseUrl": ".", "paths": { "@/": ["src/"] } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }
Recommended Structure
src/ ├── server/ │ ├── trpc.ts # tRPC instance, base procedures │ ├── context.ts # Request context │ └── routers/ │ ├── _app.ts # Root router (merges all) │ ├── user.ts # User procedures │ └── post.ts # Post procedures ├── lib/ │ ├── prisma.ts # Prisma singleton │ ├── logger.ts # Pino configuration │ └── env.ts # Environment validation ├── schemas/ │ ├── user.schema.ts # User Zod schemas │ └── common.schema.ts # Shared schemas ├── middleware/ │ ├── auth.ts # Auth middleware │ └── logging.ts # Request logging └── index.ts # Entry point
prisma/ ├── schema.prisma # Database schema └── migrations/ # Migration history
test/ ├── setup.ts # Test setup └── context.ts # Mock context factory
- API Layer Decision
tRPC vs REST Decision Tree
Building an API? │ ├─► Who are the clients? │ │ │ ├─► Only TypeScript (Next.js, React) │ │ └─► Pure tRPC ✓ │ │ - End-to-end type safety │ │ - No code generation │ │ - Automatic request batching │ │ │ ├─► TypeScript + external clients (mobile, third-party) │ │ └─► tRPC + OpenAPI ✓ │ │ - Type-safe internal API │ │ - REST endpoints for external │ │ - Swagger documentation │ │ │ └─► Only external/non-TypeScript clients │ └─► Express + OpenAPI ✓ │ - Standard REST │ - Maximum compatibility
tRPC Quick Setup
→ See [backend-trpc] for full guide
// src/server/trpc.ts import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod';
interface Context { user?: { id: string; role: string }; db: PrismaClient; log: Logger; }
const t = initTRPC.context<Context>().create();
export const router = t.router; export const publicProcedure = t.procedure; export const middleware = t.middleware;
// Auth middleware const isAuthed = middleware(async ({ ctx, next }) => { if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' }); return next({ ctx: { user: ctx.user } }); });
export const protectedProcedure = publicProcedure.use(isAuthed);
When to Add OpenAPI
→ See [backend-trpc-openapi] for full guide
// Add OpenAPI meta to expose as REST .meta({ openapi: { method: 'GET', path: '/users/{id}', tags: ['Users'], }, })
Scenario Recommendation
Internal TypeScript clients Pure tRPC
Third-party integrations tRPC + OpenAPI
Public API documentation tRPC + OpenAPI
Mobile apps (non-React Native) tRPC + OpenAPI
Microservices (mixed languages) OpenAPI/REST
- Authentication Decision
Auth.js vs Passport.js
Need authentication? │ ├─► Next.js App Router? │ └─► Auth.js (NextAuth.js v5) ✓ │ - Native Next.js integration │ - OAuth providers built-in │ - Serverless/Edge ready │ └─► Express.js / Pure API? └─► Passport.js ✓ - JWT authentication - 500+ strategies - Maximum control
Auth.js Quick Setup (Next.js)
→ See [backend-auth-js] for full guide
// auth.ts import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; import { PrismaAdapter } from '@auth/prisma-adapter';
export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), session: { strategy: 'jwt' }, providers: [GitHub], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { session.user.id = token.id as string; return session; }, }, });
Passport.js Quick Setup (Express)
→ See [backend-passport-js] for full guide
// src/strategies/jwt.strategy.ts import passport from 'passport'; import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
passport.use(new JwtStrategy({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: process.env.JWT_SECRET!, }, async (payload, done) => { const user = await prisma.user.findUnique({ where: { id: payload.sub } }); return done(null, user || false); }));
Feature Auth.js Passport.js
Best for Next.js Express
OAuth setup Minimal Manual
JWT support Built-in passport-jwt
Session storage JWT/DB Manual
Serverless Yes Limited
Strategies ~20 500+
- Database Layer (Prisma)
→ See [backend-prisma] for full guide
Singleton Pattern (Required)
// src/lib/prisma.ts import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], });
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Essential Schema Patterns
// prisma/schema.prisma model User { id String @id @default(cuid()) email String @unique name String? role Role @default(USER) posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
@@index([email]) }
model Post { id String @id @default(cuid()) title String @db.VarChar(255) published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String
@@index([authorId]) @@index([createdAt(sort: Desc)]) }
enum Role { USER ADMIN }
Migration Commands
npx prisma migrate dev --name init # Development npx prisma migrate deploy # Production npx prisma generate # Regenerate client npx prisma studio # GUI viewer
- Validation Layer (Zod)
→ See [backend-zod] for full guide
Core Patterns
// src/schemas/user.schema.ts import { z } from 'zod';
// Base schema export const UserSchema = z.object({ id: z.string().cuid(), email: z.string().email(), name: z.string().min(2).max(100), role: z.enum(['USER', 'ADMIN']), });
// Derive variations export const CreateUserSchema = UserSchema.omit({ id: true }); export const UpdateUserSchema = CreateUserSchema.partial();
// Infer types export type User = z.infer<typeof UserSchema>; export type CreateUser = z.infer<typeof CreateUserSchema>;
Common Schemas
// src/schemas/common.schema.ts export const PaginationSchema = z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), });
export const IdSchema = z.object({ id: z.string().cuid(), });
// Environment validation export const EnvSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), PORT: z.coerce.number().default(3000), });
export const env = EnvSchema.parse(process.env);
Zod + tRPC Integration
// Zod validates input automatically export const userRouter = router({ create: protectedProcedure .input(CreateUserSchema) .mutation(({ input, ctx }) => { // input is typed as CreateUser return ctx.db.user.create({ data: input }); }), });
- Logging (Pino)
→ See [backend-pino] for full guide
Configuration
// src/lib/logger.ts import pino from 'pino';
const isDev = process.env.NODE_ENV === 'development';
export const logger = pino({ level: process.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),
transport: isDev ? { target: 'pino-pretty', options: { colorize: true }, } : undefined,
redact: { paths: ['password', 'token', '*.password', 'req.headers.authorization'], censor: '[REDACTED]', },
base: { service: process.env.SERVICE_NAME || 'api', env: process.env.NODE_ENV, }, });
Request Logging Middleware
// src/middleware/logging.ts export function requestLogger(req: Request, res: Response, next: NextFunction) { const requestId = req.headers['x-request-id'] || randomUUID(); const start = Date.now();
req.log = logger.child({ requestId, method: req.method, path: req.path }); req.log.info('Request started');
res.on('finish', () => { req.log.info({ statusCode: res.statusCode, duration: Date.now() - start }, 'Request completed'); });
next(); }
Structured Logging Rules
// ❌ String interpolation
logger.info(User ${userId} logged in from ${ip});
// ✅ Structured objects logger.info({ userId, ip, action: 'login' }, 'User logged in');
- Testing (Vitest)
→ See [backend-vitest] for full guide
Configuration
// vitest.config.ts import { defineConfig } from 'vitest/config'; import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ plugins: [tsconfigPaths()], test: { globals: true, environment: 'node', include: ['/*.test.ts'], setupFiles: ['./test/setup.ts'], coverage: { provider: 'v8', include: ['src//*.ts'], }, }, });
Mock Context Factory
// test/context.ts import { mockDeep, DeepMockProxy } from 'vitest-mock-extended'; import { PrismaClient } from '@prisma/client';
export type MockContext = { prisma: DeepMockProxy<PrismaClient>; user: { id: string; role: string } | null; };
export const createMockContext = (user = null): MockContext => ({ prisma: mockDeep<PrismaClient>(), user, });
Testing tRPC Procedures
// src/server/routers/user.test.ts import { describe, it, expect, beforeEach } from 'vitest'; import { createCallerFactory } from '../trpc'; import { userRouter } from './user'; import { createMockContext } from '@/test/context';
describe('User Router', () => { let mockCtx: MockContext; const createCaller = createCallerFactory(userRouter);
beforeEach(() => { mockCtx = createMockContext(); });
it('should return user by id', async () => { const mockUser = { id: '1', email: 'test@example.com', name: 'Test' }; mockCtx.prisma.user.findUnique.mockResolvedValue(mockUser);
const caller = createCaller(mockCtx);
const result = await caller.getById({ id: '1' });
expect(result).toEqual(mockUser);
});
it('should reject unauthenticated create', async () => { const caller = createCaller(mockCtx); // user is null
await expect(caller.create({ email: 'new@example.com', name: 'New' }))
.rejects.toThrow('UNAUTHORIZED');
}); });
Test Scripts
{ "scripts": { "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" } }
- Deployment (Docker)
→ See [docker-node] for full guide
Multi-Stage Dockerfile
Stage 1: Dependencies
FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --only=production && npm cache clean --force
Stage 2: Build
FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY tsconfig.json ./ COPY prisma ./prisma/ COPY src ./src/ RUN npx prisma generate RUN npm run build
Stage 3: Production
FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 -G nodejs
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist COPY --from=builder --chown=nodejs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nodejs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
USER nodejs EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
Docker Compose (Development)
docker-compose.yml
version: '3.8'
services: app: build: context: . target: builder ports: - "3000:3000" environment: NODE_ENV: development DATABASE_URL: postgresql://postgres:postgres@postgres:5432/myapp volumes: - ./src:/app/src:delegated depends_on: postgres: condition: service_healthy command: npm run dev
postgres: image: postgres:15-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: myapp ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 5s retries: 10
volumes: postgres_data:
Commands
Development
docker-compose up # Start all docker-compose up --build # Rebuild docker-compose down -v # Stop + reset DB
Production
docker build -t myapp:latest . docker run -p 3000:3000 --env-file .env.production myapp:latest
- Security Checklist
Authentication
✓ Hash passwords with argon2/bcrypt ✓ Use short-lived access tokens (15min) ✓ Store refresh tokens in httpOnly cookies ✓ Validate JWT on every request ✓ Use HTTPS in production
Input Validation
✓ Validate ALL inputs with Zod ✓ Use z.coerce for query parameters ✓ Sanitize user-generated content ✓ Limit request body size
Database
✓ Use Prisma (prevents SQL injection) ✓ Never expose raw database errors ✓ Use transactions for multi-step operations ✓ Add indexes for frequent queries
Logging
✓ Redact sensitive data (passwords, tokens) ✓ Include request IDs for tracing ✓ Don't log PII in production ✓ Use structured JSON logs
- Error Handling
tRPC Error Codes
Code HTTP Use Case
BAD_REQUEST
400 Invalid input
UNAUTHORIZED
401 No/invalid auth
FORBIDDEN
403 No permission
NOT_FOUND
404 Resource missing
CONFLICT
409 Already exists
INTERNAL_SERVER_ERROR
500 Unexpected error
Error Handling Pattern
import { TRPCError } from '@trpc/server';
// In procedures const user = await ctx.db.user.findUnique({ where: { id } }); if (!user) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' }); }
// Global error formatter const t = initTRPC.context<Context>().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof z.ZodError ? error.cause.flatten() : null, }, }; }, });
- Common Patterns
Cursor-Based Pagination
list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), })) .query(async ({ input, ctx }) => { const items = await ctx.db.post.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: 'desc' }, });
let nextCursor: string | undefined;
if (items.length > input.limit) {
nextCursor = items.pop()?.id;
}
return { items, nextCursor };
}),
Role-Based Authorization
const hasRole = (role: string) => middleware(async ({ ctx, next }) => { if (ctx.user?.role !== role) { throw new TRPCError({ code: 'FORBIDDEN' }); } return next(); });
export const adminProcedure = protectedProcedure.use(hasRole('ADMIN'));
Transactions
const result = await ctx.db.$transaction(async (tx) => { const sender = await tx.account.update({ where: { id: senderId }, data: { balance: { decrement: amount } }, });
if (sender.balance < 0) throw new Error('Insufficient funds');
await tx.account.update({ where: { id: receiverId }, data: { balance: { increment: amount } }, });
return sender; });
- Skill Reference Map
Task Primary Skill When to Use
Type-safe API backend-trpc Full-stack TypeScript
REST endpoints backend-trpc-openapi External clients need REST
Next.js auth backend-auth-js OAuth, sessions in Next.js
Express auth backend-passport-js JWT APIs, custom auth
Database ORM backend-prisma Any SQL database
Input validation backend-zod ALL input validation
Structured logging backend-pino Production observability
Unit testing backend-vitest tRPC, Zod, utilities
Containerization docker-node Deployment, CI/CD
- Quick Start Templates
Complete tRPC Router
// src/server/routers/user.ts import { z } from 'zod'; import { router, publicProcedure, protectedProcedure } from '../trpc'; import { TRPCError } from '@trpc/server';
const CreateUserSchema = z.object({ email: z.string().email(), name: z.string().min(2).max(100), });
export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const user = await ctx.db.user.findUnique({ where: { id: input.id } }); if (!user) throw new TRPCError({ code: 'NOT_FOUND' }); return user; }),
list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), })) .query(async ({ input, ctx }) => { const items = await ctx.db.user.findMany({ take: input.limit + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: 'desc' }, });
let nextCursor: string | undefined;
if (items.length > input.limit) nextCursor = items.pop()?.id;
return { items, nextCursor };
}),
create: protectedProcedure .input(CreateUserSchema) .mutation(async ({ input, ctx }) => { return ctx.db.user.create({ data: input }); }),
update: protectedProcedure .input(z.object({ id: z.string(), name: z.string().min(2).optional(), })) .mutation(async ({ input, ctx }) => { const { id, ...data } = input; return ctx.db.user.update({ where: { id }, data }); }),
delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { await ctx.db.user.delete({ where: { id: input.id } }); return { success: true }; }), });
Express Server with tRPC
// src/index.ts import express from 'express'; import cors from 'cors'; import { createExpressMiddleware } from '@trpc/server/adapters/express'; import { appRouter } from './server/routers/_app'; import { createContext } from './server/context'; import { logger } from './lib/logger'; import { requestLogger } from './middleware/logging';
const app = express();
app.use(cors()); app.use(express.json()); app.use(requestLogger);
app.get('/health', async (req, res) => {
try {
await prisma.$queryRawSELECT 1;
res.json({ status: 'healthy' });
} catch {
res.status(503).json({ status: 'unhealthy' });
}
});
app.use('/trpc', createExpressMiddleware({ router: appRouter, createContext, }));
const port = process.env.PORT || 3000; app.listen(port, () => { logger.info({ port }, 'Server started'); });
External Resources
-
tRPC: https://trpc.io/docs
-
Prisma: https://prisma.io/docs
-
Zod: https://zod.dev
-
Auth.js: https://authjs.dev
-
Passport.js: https://passportjs.org
-
Pino: https://getpino.io
-
Vitest: https://vitest.dev
-
Docker: https://docs.docker.com
For latest API of any library → use context7 skill