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

bash
mkdir 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:

prisma
generator 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:

typescript
export 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:

typescript
import { 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:

typescript
import { 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:

typescript
import { 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:

typescript
import 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:

typescript
import { 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:

typescript
import 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:

typescript
import { 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:

typescript
import 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

EndpointMethodDescriptionAuth
/healthGETHealth checkNo
/api/auth/registerPOSTCreate accountNo
/api/auth/loginPOSTLoginNo
/api/auth/refreshPOSTRefresh tokensNo
/api/auth/logoutPOSTLogoutNo

Extending the API

Frequently Asked Questions

  1. Create schema in src/schemas/
  2. Create service in src/services/
  3. Create routes in src/routes/
  4. Add routes to src/index.ts

Use the requireRole middleware:

typescript
router.get('/admin/users', authenticate(), requireRole('ADMIN'), handler);