A RESTful API for managing blog posts and comments, built with a clean layered architecture using Node.js, Express, TypeScript, and PostgreSQL.
| Layer | Technology |
|---|---|
| Runtime | Node.js 20+ (ESM) |
| Framework | Express.js |
| Language | TypeScript (strict mode) |
| ORM | Prisma |
| Database | PostgreSQL |
| Validation | express-validator, Zod |
| Auth | JWT (jsonwebtoken), bcrypt |
| Security | helmet, express-rate-limit, cors |
- Node.js 20+
- PostgreSQL 14+
git clone https://github.com/hongdat-pham/blog-api.git
cd blog-apinpm installcp .env.example .envFill in your .env:
PORT=3000
NODE_ENV=development
APP_NAME=Blog Api
API_KEY=blog-secret-key
ADMIN_KEY=my-admin-key
JWT_SECRET=super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=1h
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/blogdb"npx prisma migrate devnpx prisma db seed# Development (with hot reload)
npm run dev
# Production
npm run build
npm startServer runs at http://localhost:3000
This API uses JWT (JSON Web Token) for authentication.
POST /auth/register → receive verifyToken
POST /auth/verify-email { token } → verify email
POST /auth/login → receive accessToken (15min) + refreshToken (7d)
# Use accessToken in Authorization header:
Authorization: Bearer <accessToken>
# When accessToken expires:
POST /auth/refresh { refreshToken } → receive new token pair
# End session:
POST /auth/logout { refreshToken }
| Role | Permissions |
|---|---|
user |
Create posts (verified only), add comments |
admin |
All user permissions + delete any post, publish |
- Only verified users (email verified) can create posts
- Access tokens expire in 15 minutes
- Refresh tokens expire in 7 days and rotate on each use
- Replay attack protection: using a refresh token twice revokes all sessions
| Method | URL | Auth required | Description |
|---|---|---|---|
| POST | /auth/register |
No | Register and receive verifyToken |
| POST | /auth/verify-email |
No | Verify email with token |
| POST | /auth/login |
No | Login and receive token pair |
| POST | /auth/refresh |
No | Refresh access token |
| POST | /auth/logout |
No | Logout and invalidate refresh token |
| GET | /auth/me |
Yes | Get current user profile |
| Method | URL | Auth required | Description |
|---|---|---|---|
| GET | / |
No | API info and available endpoints |
| GET | /stats |
Yes | Aggregated statistics |
| Method | URL | Auth required | Description |
|---|---|---|---|
| GET | /posts |
No | List posts (pagination + search) |
| POST | /posts |
Yes (verified) | Create a new post |
| GET | /posts/:id |
No | Get a single post with author and comments |
| PATCH | /posts/:id |
Yes | Update a post |
| DELETE | /posts/:id |
Yes (admin) | Delete a post |
| POST | /posts/:id/publish |
Yes (admin) | Publish a post |
Query parameters for GET /posts:
| Parameter | Type | Description |
|---|---|---|
page |
number | Page number (default: 1) |
limit |
number | Items per page (default: 10) |
search |
string | Filter by title or content |
| Method | URL | Auth required | Description |
|---|---|---|---|
| GET | /posts/:id/comments |
No | List comments on a post |
| POST | /posts/:id/comments |
Yes | Add a comment to a post |
| DELETE | /posts/:id/comments/:commentId |
Yes (admin) | Delete a comment |
| Method | URL | Auth required | Description |
|---|---|---|---|
| GET | /users/:id/posts |
No | Get all posts by a user (with comment counts) |
Request
POST /auth/register
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com",
"password": "Password123"
}Response 201 Created
{
"message": "Registration successful. Please verify your email.",
"verifyToken": "a3f8c2..."
}Request
POST /auth/verify-email
Content-Type: application/json
{
"token": "a3f8c2..."
}Response 200 OK
{
"message": "Email verified successfully"
}Request
POST /auth/login
Content-Type: application/json
{
"email": "john@example.com",
"password": "Password123"
}Response 200 OK
{
"accessToken": "eyJhbGci...",
"refreshToken": "d4e5f6..."
}Request
GET /auth/me
Authorization: Bearer eyJhbGci...
Response 200 OK
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"role": "user",
"emailVerified": true,
"createdAt": "2024-01-01T00:00:00.000Z"
}Request
GET /posts?page=1&limit=10
Response 200 OK
{
"data": [
{
"id": 1,
"title": "My first blog",
"content": "Hello world",
"published": true,
"authorId": 1,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}
],
"total": 5,
"page": 1,
"limit": 10
}Request
POST /posts
Authorization: Bearer eyJhbGci...
Content-Type: application/json
{
"title": "My first blog",
"content": "Hello world content here",
"tags": ["nodejs", "express"]
}Response 201 Created
{
"id": 1,
"title": "My first blog",
"content": "Hello world content here",
"published": false,
"authorId": 1,
"tags": ["nodejs", "express"],
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}Request
GET /stats
Authorization: Bearer eyJhbGci...
Response 200 OK
{
"totalPosts": 5,
"totalComments": 10,
"totalUsers": 3,
"publishedPosts": 4
}All errors follow a consistent shape:
{
"error": "Post not found",
"statusCode": 404
}| Status | Meaning |
|---|---|
400 |
Bad request — invalid input |
401 |
Unauthorized — missing or invalid token |
403 |
Forbidden — insufficient permissions or unverified email |
404 |
Resource not found |
409 |
Conflict — duplicate resource |
422 |
Validation error — invalid request body |
429 |
Too many requests — rate limit exceeded |
500 |
Internal server error |
| Feature | Details |
|---|---|
| Helmet | Secure HTTP headers on all responses |
| Rate limiting | 100 req/15min global, 5 req/15min on /auth |
| CORS | Restricted to ALLOWED_ORIGINSin .env |
| Passwords | Hashed with bcrypt (salt rounds: 10) |
| Refresh tokens | Stored hashed in DB, rotate on each use |
blog-api/
├── prisma/
│ ├── schema.prisma # Database schema
│ ├── migrations/ # Migration history
│ └── seed.ts # Seed script (3 users, 5 posts, 10 comments)
├── src/
│ ├── app.ts # Express app setup
│ ├── server.ts # Entry point
│ ├── config.ts # Zod-validated environment config
│ ├── database/
│ │ └── prisma.ts # PrismaClient singleton
│ ├── auth/ # Auth feature module
│ │ ├── auth.controller.ts
│ │ ├── auth.routes.ts
│ │ └── auth.service.ts
│ ├── posts/ # Feature module
│ │ ├── posts.controller.ts
│ │ ├── posts.routes.ts
│ │ ├── posts.service.ts
│ │ └── posts.model.ts
│ ├── comments/ # Feature module
│ │ ├── comments.controller.ts
│ │ ├── comments.routes.ts
│ │ ├── comments.service.ts
│ │ └── comments.model.ts
│ ├── users/
│ ├── stats/
│ ├── middlewares/
│ │ ├── auth.ts # JWT authentication
│ │ ├── requireRole.ts # Role-based access control
│ │ ├── requireVerified.ts# Email verification guard
│ │ ├── errorHandler.ts # Global error handler
│ │ ├── logger.ts # Request logger
│ │ └── validate.ts # express-validator runner
│ ├── errors/ # Typed error hierarchy
│ │ ├── AppError.ts
│ │ ├── NotFoundError.ts
│ │ ├── ConflictError.ts
│ │ ├── UnauthorizedError.ts
│ │ ├── ValidationError.ts
│ │ └── index.ts
│ └── types/
│ ├── user.types.ts
│ ├── post.types.ts
│ ├── common.types.ts
│ └── express.d.ts # Express Request augmentation (req.user)
├── .env.example
├── package.json
└── tsconfig.json
npm run dev # Start with hot reload (tsx watch)
npm run build # Compile TypeScript → dist/
npm start # Run compiled output