Template
2 hours
intermediate
REST API Backend
Production-ready API backend with authentication, validation, rate limiting, and comprehensive error handling.
What you'll build
A robust API backend ready to serve your frontend or mobile app.
Prerequisites
- Node.js 20+ installed
- PostgreSQL or SQLite for development
Overview
This template provides a production-ready REST API with:
- Authentication - JWT with refresh tokens
- Validation - Request validation with Zod
- Rate Limiting - Per-user and per-IP limits
- Error Handling - Consistent error responses
- Logging - Structured logging for debugging
- Documentation - Auto-generated OpenAPI docs
Project Structure
api-backend/
├── src/
│ ├── routes/
│ │ ├── auth.ts
│ │ ├── users.ts
│ │ └── index.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ ├── validate.ts
│ │ ├── rateLimit.ts
│ │ └── errorHandler.ts
│ ├── services/
│ │ ├── authService.ts
│ │ └── userService.ts
│ ├── lib/
│ │ ├── db.ts
│ │ ├── logger.ts
│ │ └── errors.ts
│ ├── types/
│ │ └── index.ts
│ ├── schemas/
│ │ ├── auth.ts
│ │ └── user.ts
│ └── index.ts
├── prisma/
│ └── schema.prisma
├── .env.example
└── package.json
Step 1: Initialize Project
bashmkdir api-backend && cd api-backend npm init -y npm install express @prisma/client zod bcryptjs jsonwebtoken express-rate-limit helmet cors pino pino-pretty npm install -D typescript @types/node @types/express @types/bcryptjs @types/jsonwebtoken prisma tsx npx tsc --init
Update tsconfig.json:
json{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "esModuleInterop": true, "outDir": "./dist", "rootDir": "./src", "skipLibCheck": true }, "include": ["src/**/*"] }
Step 2: Database Schema
Create prisma/schema.prisma:
prismagenerator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique password String name String? role Role @default(USER) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt refreshTokens RefreshToken[] } model RefreshToken { id String @id @default(cuid()) token String @unique userId String expiresAt DateTime createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([token]) @@index([userId]) } enum Role { USER ADMIN }
Step 3: Error Handling
Create src/lib/errors.ts:
typescriptexport class ApiError extends Error { constructor( public statusCode: number, public code: string, message: string, public details?: Record<string, unknown> ) { super(message); this.name = 'ApiError'; } static badRequest(message: string, code = 'BAD_REQUEST', details?: Record<string, unknown>) { return new ApiError(400, code, message, details); } static unauthorized(message = 'Unauthorized', code = 'UNAUTHORIZED') { return new ApiError(401, code, message); } static forbidden(message = 'Forbidden', code = 'FORBIDDEN') { return new ApiError(403, code, message); } static notFound(message = 'Not found', code = 'NOT_FOUND') { return new ApiError(404, code, message); } static conflict(message: string, code = 'CONFLICT') { return new ApiError(409, code, message); } static tooManyRequests(message = 'Too many requests', retryAfter?: number) { return new ApiError(429, 'RATE_LIMITED', message, { retryAfter }); } static internal(message = 'Internal server error') { return new ApiError(500, 'INTERNAL_ERROR', message); } toJSON() { return { error: { code: this.code, message: this.message, ...(this.details && { details: this.details }), }, }; } }
Step 4: Validation Schemas
Create src/schemas/auth.ts:
typescriptimport { z } from 'zod'; export const registerSchema = z.object({ body: z.object({ email: z.string().email('Invalid email format'), password: z .string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Password must contain uppercase letter') .regex(/[0-9]/, 'Password must contain number'), name: z.string().min(1).max(100).optional(), }), }); export const loginSchema = z.object({ body: z.object({ email: z.string().email(), password: z.string().min(1), }), }); export const refreshSchema = z.object({ body: z.object({ refreshToken: z.string().min(1), }), }); export type RegisterInput = z.infer<typeof registerSchema>['body']; export type LoginInput = z.infer<typeof loginSchema>['body'];
Step 5: Validation Middleware
Create src/middleware/validate.ts:
typescriptimport { Request, Response, NextFunction } from 'express'; import { AnyZodObject, ZodError } from 'zod'; import { ApiError } from '../lib/errors'; export function validate(schema: AnyZodObject) { return async (req: Request, res: Response, next: NextFunction) => { try { await schema.parseAsync({ body: req.body, query: req.query, params: req.params, }); next(); } catch (error) { if (error instanceof ZodError) { const details = error.errors.reduce( (acc, err) => { const path = err.path.join('.'); acc[path] = err.message; return acc; }, {} as Record<string, string> ); next(ApiError.badRequest('Validation failed', 'VALIDATION_ERROR', details)); } else { next(error); } } }; }
Step 6: Authentication Middleware
Create src/middleware/auth.ts:
typescriptimport { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { prisma } from '../lib/db'; import { ApiError } from '../lib/errors'; export interface AuthRequest extends Request { user?: { id: string; email: string; role: string; }; } export function authenticate(options?: { optional?: boolean }) { return async (req: AuthRequest, res: Response, next: NextFunction) => { try { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { if (options?.optional) { return next(); } throw ApiError.unauthorized('Missing authorization header'); } const token = authHeader.slice(7); const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; type: string; }; if (payload.type !== 'access') { throw ApiError.unauthorized('Invalid token type'); } const user = await prisma.user.findUnique({ where: { id: payload.userId }, select: { id: true, email: true, role: true, isActive: true }, }); if (!user || !user.isActive) { throw ApiError.unauthorized('User not found or inactive'); } req.user = user; next(); } catch (error) { if (error instanceof jwt.JsonWebTokenError) { next(ApiError.unauthorized('Invalid token')); } else if (error instanceof jwt.TokenExpiredError) { next(ApiError.unauthorized('Token expired', 'TOKEN_EXPIRED')); } else { next(error); } } }; } export function requireRole(...roles: string[]) { return (req: AuthRequest, res: Response, next: NextFunction) => { if (!req.user) { return next(ApiError.unauthorized()); } if (!roles.includes(req.user.role)) { return next(ApiError.forbidden('Insufficient permissions')); } next(); }; }
Step 7: Rate Limiting
Create src/middleware/rateLimit.ts:
typescriptimport rateLimit from 'express-rate-limit'; import { ApiError } from '../lib/errors'; export const generalLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // 100 requests per minute standardHeaders: true, legacyHeaders: false, handler: (req, res) => { const error = ApiError.tooManyRequests('Rate limit exceeded', 60); res.status(429).json(error.toJSON()); }, }); export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 attempts per 15 minutes standardHeaders: true, legacyHeaders: false, handler: (req, res) => { const error = ApiError.tooManyRequests('Too many login attempts', 900); res.status(429).json(error.toJSON()); }, }); export const createAccountLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 5, // 5 accounts per hour per IP standardHeaders: true, legacyHeaders: false, handler: (req, res) => { const error = ApiError.tooManyRequests('Account creation limit exceeded', 3600); res.status(429).json(error.toJSON()); }, });
Step 8: Error Handler
Create src/middleware/errorHandler.ts:
typescriptimport { Request, Response, NextFunction } from 'express'; import { ApiError } from '../lib/errors'; import { logger } from '../lib/logger'; export function errorHandler( error: Error, req: Request, res: Response, next: NextFunction ) { // Log error logger.error({ error: error.message, stack: error.stack, path: req.path, method: req.method, }); // Handle known errors if (error instanceof ApiError) { return res.status(error.statusCode).json(error.toJSON()); } // Handle unknown errors const apiError = ApiError.internal( process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message ); res.status(500).json(apiError.toJSON()); }
Step 9: Auth Service
Create src/services/authService.ts:
typescriptimport bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { prisma } from '../lib/db'; import { ApiError } from '../lib/errors'; import type { RegisterInput, LoginInput } from '../schemas/auth'; const ACCESS_TOKEN_EXPIRY = '15m'; const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7 days export const authService = { async register(input: RegisterInput) { const existing = await prisma.user.findUnique({ where: { email: input.email }, }); if (existing) { throw ApiError.conflict('Email already registered'); } const hashedPassword = await bcrypt.hash(input.password, 12); const user = await prisma.user.create({ data: { email: input.email, password: hashedPassword, name: input.name, }, select: { id: true, email: true, name: true, role: true }, }); const tokens = await this.generateTokens(user.id); return { user, ...tokens }; }, async login(input: LoginInput) { const user = await prisma.user.findUnique({ where: { email: input.email }, }); if (!user || !user.isActive) { throw ApiError.unauthorized('Invalid credentials'); } const valid = await bcrypt.compare(input.password, user.password); if (!valid) { throw ApiError.unauthorized('Invalid credentials'); } const tokens = await this.generateTokens(user.id); return { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens, }; }, async refresh(refreshToken: string) { const stored = await prisma.refreshToken.findUnique({ where: { token: refreshToken }, include: { user: true }, }); if (!stored || stored.expiresAt < new Date()) { if (stored) { await prisma.refreshToken.delete({ where: { id: stored.id } }); } throw ApiError.unauthorized('Invalid refresh token'); } // Delete old token await prisma.refreshToken.delete({ where: { id: stored.id } }); // Generate new tokens return this.generateTokens(stored.userId); }, async logout(refreshToken: string) { await prisma.refreshToken.deleteMany({ where: { token: refreshToken }, }); }, async generateTokens(userId: string) { const accessToken = jwt.sign( { userId, type: 'access' }, process.env.JWT_SECRET!, { expiresIn: ACCESS_TOKEN_EXPIRY } ); const refreshToken = crypto.randomBytes(64).toString('hex'); await prisma.refreshToken.create({ data: { token: refreshToken, userId, expiresAt: new Date(Date.now() + REFRESH_TOKEN_EXPIRY), }, }); return { accessToken, refreshToken }; }, };
Step 10: Auth Routes
Create src/routes/auth.ts:
typescriptimport { Router } from 'express'; import { authService } from '../services/authService'; import { validate } from '../middleware/validate'; import { authLimiter, createAccountLimiter } from '../middleware/rateLimit'; import { registerSchema, loginSchema, refreshSchema } from '../schemas/auth'; const router = Router(); router.post( '/register', createAccountLimiter, validate(registerSchema), async (req, res, next) => { try { const result = await authService.register(req.body); res.status(201).json(result); } catch (error) { next(error); } } ); router.post( '/login', authLimiter, validate(loginSchema), async (req, res, next) => { try { const result = await authService.login(req.body); res.json(result); } catch (error) { next(error); } } ); router.post( '/refresh', validate(refreshSchema), async (req, res, next) => { try { const result = await authService.refresh(req.body.refreshToken); res.json(result); } catch (error) { next(error); } } ); router.post('/logout', async (req, res, next) => { try { if (req.body.refreshToken) { await authService.logout(req.body.refreshToken); } res.status(204).send(); } catch (error) { next(error); } }); export default router;
Step 11: Main Application
Create src/index.ts:
typescriptimport express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import { generalLimiter } from './middleware/rateLimit'; import { errorHandler } from './middleware/errorHandler'; import { logger } from './lib/logger'; import authRoutes from './routes/auth'; // import userRoutes from './routes/users'; const app = express(); // Security middleware app.use(helmet()); app.use(cors({ origin: process.env.CORS_ORIGIN || '*' })); app.use(express.json({ limit: '10kb' })); // Rate limiting app.use(generalLimiter); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Routes app.use('/api/auth', authRoutes); // app.use('/api/users', userRoutes); // Error handling app.use(errorHandler); // 404 handler app.use((req, res) => { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Endpoint not found' }, }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`); });
Environment Variables
bash# Server PORT=3000 NODE_ENV=development # Database DATABASE_URL="postgresql://user:password@localhost:5432/api" # JWT JWT_SECRET="your-super-secret-key-min-32-chars" # CORS CORS_ORIGIN="http://localhost:3000"
Running the API
bash# Development npx prisma migrate dev npx tsx watch src/index.ts # Production npm run build npx prisma migrate deploy node dist/index.js
API Documentation
| Endpoint | Method | Description | Auth |
|---|---|---|---|
/health | GET | Health check | No |
/api/auth/register | POST | Create account | No |
/api/auth/login | POST | Login | No |
/api/auth/refresh | POST | Refresh tokens | No |
/api/auth/logout | POST | Logout | No |
Extending the API
Frequently Asked Questions
- Create schema in
src/schemas/ - Create service in
src/services/ - Create routes in
src/routes/ - Add routes to
src/index.ts
Use the requireRole middleware:
typescriptrouter.get('/admin/users', authenticate(), requireRole('ADMIN'), handler);