In the previous article, you built a CLI tool with AI. Now let us scale up. You are going to build a complete REST API — with a database, authentication, tests, and deployment — all driven by AI.

The project: a Notes API that lets users create, read, update, and delete notes. It includes user registration, authentication with JWT, PostgreSQL storage, and proper error handling. Simple enough to build in one session, complex enough to show real patterns.

The more clearly you describe your requirements, the better the result. So we start with a specification document — not code.

Step 1: Write the PRD

A Product Requirements Document (PRD) is the most effective way to start a large AI-driven project. It gives the AI a complete picture before it writes a single line of code.

Prompt to Claude Code:

I want to build a Notes API. Help me write a PRD.

The API should support:
- User registration and login (email + password)
- JWT-based authentication
- CRUD operations for notes (title, content, tags)
- Notes belong to users — users can only see their own
- Search notes by title or content
- Pagination for list endpoints
- Input validation on all endpoints

Tech stack:
- TypeScript with Express
- PostgreSQL with Prisma ORM
- JWT for auth with bcrypt for passwords
- Vitest for testing
- Zod for input validation

Write the PRD as a markdown document. Include:
- API endpoint list with request/response formats
- Database schema
- Authentication flow
- Error response format

Claude Code generated a detailed PRD. Here are the key parts:

## API Endpoints

### Auth
POST /api/auth/register — Register a new user
POST /api/auth/login — Login and get JWT token
POST /api/auth/refresh — Refresh an expired token

### Notes
GET    /api/notes       — List user's notes (paginated)
GET    /api/notes/:id   — Get a single note
POST   /api/notes       — Create a note
PUT    /api/notes/:id   — Update a note
DELETE /api/notes/:id   — Delete a note
GET    /api/notes/search?q=term — Search notes

## Database Schema

User: id, email (unique), passwordHash, name, createdAt
Note: id, title, content, tags (string array),
      userId (foreign key), createdAt, updatedAt

## Error Format
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [...]
  }
}

What I reviewed and changed:

  • Removed the refresh token endpoint — it adds complexity and we want to keep this focused. JWT with a 24-hour expiration is sufficient for this project.
  • Added a deletedAt field to the User schema for soft deletes.
  • Changed tags from a string array to a separate Tag table with a many-to-many relation. Then I changed it back — a simple string array is fine for this scope and Prisma supports it natively with PostgreSQL.

The PRD review is the most important checkpoint. If the PRD is wrong, everything built from it will be wrong.

Step 2: Scaffold the Project

Prompt:

Read the PRD. Scaffold the project structure.

Create:
- package.json with all dependencies
- tsconfig.json (strict mode)
- Prisma schema matching the PRD database design
- Express app with middleware setup (cors, json parsing,
  error handling)
- Route files for auth and notes (empty handlers)
- Zod schemas for all request bodies
- Project structure following the PRD architecture

Do not implement the handlers yet. Just the structure.

Claude Code created the full project scaffold:

notes-api/
├── package.json
├── tsconfig.json
├── prisma/
│   └── schema.prisma
├── src/
│   ├── index.ts              # Express app setup
│   ├── routes/
│   │   ├── auth.ts           # Auth route definitions
│   │   └── notes.ts          # Notes route definitions
│   ├── controllers/
│   │   ├── authController.ts
│   │   └── notesController.ts
│   ├── services/
│   │   ├── authService.ts
│   │   └── notesService.ts
│   ├── middleware/
│   │   ├── auth.ts           # JWT verification
│   │   ├── validate.ts       # Zod validation middleware
│   │   └── errorHandler.ts   # Global error handler
│   ├── schemas/
│   │   ├── auth.ts           # Zod schemas for auth
│   │   └── notes.ts          # Zod schemas for notes
│   ├── lib/
│   │   └── prisma.ts         # Prisma client singleton
│   └── types/
│       └── express.d.ts      # Express type extensions
└── tests/
    ├── setup.ts
    ├── auth.test.ts
    └── notes.test.ts

The Prisma schema:

model User {
  id           String   @id @default(cuid())
  email        String   @unique
  passwordHash String
  name         String
  createdAt    DateTime @default(now())
  deletedAt    DateTime?
  notes        Note[]
}

model Note {
  id        String   @id @default(cuid())
  title     String
  content   String
  tags      String[]
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

What I checked:

  • Prisma schema matched the PRD
  • The middleware stack was ordered correctly (cors first, then json, then auth, then routes, then error handler)
  • TypeScript config had strict: true
  • The Express type extension correctly added user to the Request type

Step 3: Implement Authentication

Authentication is security-critical code. I asked AI to write it but reviewed every line carefully.

Prompt:

Implement the auth service and controller.

authService.ts:
- register: validate email uniqueness, hash password with
  bcrypt (12 rounds), create user, return JWT
- login: find user by email, compare password hash,
  return JWT
- JWT payload: { userId, email }
- JWT expiration: 24 hours
- Use the JWT_SECRET from environment variables

authController.ts:
- register handler: validate with Zod, call service, return
  201 with token
- login handler: validate with Zod, call service, return
  200 with token

middleware/auth.ts:
- Extract Bearer token from Authorization header
- Verify JWT and attach user to request
- Return 401 with clear error message on failure

Important:
- Do NOT store the JWT secret in code
- Do NOT log passwords or tokens
- Use constant-time comparison for passwords (bcrypt
  handles this internally)
- Return generic "Invalid credentials" for both wrong
  email and wrong password (prevent user enumeration)

Claude Code generated the auth code. Key parts of the service:

import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { prisma } from '../lib/prisma';

const JWT_SECRET = process.env.JWT_SECRET;
const SALT_ROUNDS = 12;
const TOKEN_EXPIRY = '24h';

if (!JWT_SECRET) {
  throw new Error('JWT_SECRET environment variable is required');
}

export async function register(
  email: string,
  password: string,
  name: string
): Promise<{ token: string; user: { id: string; email: string; name: string } }> {
  const existing = await prisma.user.findUnique({
    where: { email: email.toLowerCase() },
  });

  if (existing) {
    throw new AppError('Email already registered', 409);
  }

  const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);

  const user = await prisma.user.create({
    data: {
      email: email.toLowerCase(),
      passwordHash,
      name,
    },
    select: { id: true, email: true, name: true },
  });

  const token = jwt.sign(
    { userId: user.id, email: user.email },
    JWT_SECRET,
    { expiresIn: TOKEN_EXPIRY }
  );

  return { token, user };
}

export async function login(
  email: string,
  password: string
): Promise<{ token: string; user: { id: string; email: string; name: string } }> {
  const user = await prisma.user.findUnique({
    where: { email: email.toLowerCase(), deletedAt: null },
  });

  if (!user) {
    throw new AppError('Invalid credentials', 401);
  }

  const valid = await bcrypt.compare(password, user.passwordHash);

  if (!valid) {
    throw new AppError('Invalid credentials', 401);
  }

  const token = jwt.sign(
    { userId: user.id, email: user.email },
    JWT_SECRET,
    { expiresIn: TOKEN_EXPIRY }
  );

  return {
    token,
    user: { id: user.id, email: user.email, name: user.name },
  };
}

Security review — what I checked:

  1. Password hashing: bcrypt with 12 rounds — correct and secure.
  2. User enumeration prevention: Both wrong email and wrong password return “Invalid credentials” — correct.
  3. Email normalization: .toLowerCase() on email — correct.
  4. JWT secret validation: Throws on startup if missing — correct.
  5. Password hash not exposed: The select clause only returns id, email, name — correct.
  6. Soft delete check: Login filters by deletedAt: null — correct.

What I fixed:

The middleware did not handle malformed tokens (not just expired or invalid, but tokens that are not even valid JWT format). I added a try/catch around jwt.verify to catch JsonWebTokenError separately from TokenExpiredError.

Step 4: Implement Notes CRUD

Prompt:

Implement the notes service and controller.

notesService.ts:
- create: create a note for the authenticated user
- findAll: list notes for a user with pagination
  (page and limit query params, default page=1 limit=20)
- findById: get a single note (verify it belongs to the user)
- update: update title, content, or tags (verify ownership)
- delete: delete a note (verify ownership)
- search: full-text search on title and content

Return pagination metadata: { data, total, page, limit,
totalPages }

notesController.ts:
- Wire up routes to service functions
- Validate all inputs with Zod schemas
- Return appropriate HTTP status codes
  (200, 201, 204, 400, 401, 404)

Claude Code generated clean CRUD code. The pagination worked correctly. The ownership checks were in every operation.

Issue found: The search implementation used Prisma’s contains filter, which does a case-sensitive LIKE query. For production use, you would want PostgreSQL full-text search. But for this project, the simple approach is fine.

What I fixed:

The findById method returned a 500 error instead of 404 when the note did not exist. The service threw a generic error, but the error handler did not recognize it. I asked Claude Code to use the AppError class with a 404 status code, matching the auth pattern.

Step 5: Database Setup and Migrations

Prompt:

Set up the database configuration.

Create:
- .env.example with all required environment variables
- Database migration for the initial schema
- Seed script with sample data (2 users, 5 notes each)
- Test database configuration (use a separate test database)

Commands:
- npm run db:migrate  run migrations
- npm run db:seed  seed sample data
- npm run db:reset  reset and re-seed

Claude Code generated the migration and seed script. The seed script created users with hashed passwords (not plain text — it correctly used bcrypt).

Important note about AI and migrations: AI generates the initial migration well. But subsequent migrations — the ones that modify an existing schema — are where AI struggles. It sometimes generates migrations that drop and recreate tables instead of altering them, or it misses data migration steps.

For this project with a single initial migration, it was fine. For production projects with existing data, always review migration SQL carefully.

Step 6: Write Tests

Prompt:

Write integration tests for the entire API.

Use Vitest with supertest for HTTP testing.
Use a real test database (configured in .env.test).

Test setup:
- Before all: run migrations on test database
- Before each: clean all tables
- After all: disconnect

Auth tests:
- Register with valid data
- Register with duplicate email (409)
- Register with invalid email (400)
- Login with correct credentials
- Login with wrong password (401)
- Login with non-existent email (401)
- Access protected route without token (401)
- Access protected route with expired token (401)

Notes tests (all require auth):
- Create a note
- List notes (check pagination metadata)
- Get a note by ID
- Get another user's note (404, not 403 — prevent
  enumeration)
- Update a note
- Update another user's note (404)
- Delete a note
- Search notes by title
- Search notes by content

Claude Code generated 18 integration tests. Fifteen passed on the first run.

Three failures and how I fixed them:

  1. Test database not configured. The test setup tried to use the development database. I created .env.test with the correct test database URL and updated the test config.

  2. Table cleanup order. The beforeEach cleanup deleted users before notes, causing a foreign key constraint error. I asked Claude Code to reverse the cleanup order.

  3. Token generation in tests. The tests generated their own JWT tokens instead of going through the register endpoint, but used a different secret than the one in .env.test. I changed the tests to use the register endpoint to get real tokens.

Final test run:

$ npm test

 ✓ tests/auth.test.ts (8 tests) 1.2s
 ✓ tests/notes.test.ts (10 tests) 1.8s

 Test Files  2 passed (2)
      Tests  18 passed (18)
   Start at  14:32:15
   Duration  3.2s

Step 7: Deploy

Prompt:

Create a Dockerfile and docker-compose.yml for deployment.

Requirements:
- Multi-stage Docker build (build + production stages)
- Production image should be minimal (node:20-alpine)
- docker-compose with: app + PostgreSQL
- Environment variables for configuration
- Health check endpoint at GET /health
- Run database migrations on startup

Also create a simple deployment script for a VPS:
- Pull latest code
- Build Docker image
- Run migrations
- Restart the container with zero downtime

Claude Code generated a clean Dockerfile and docker-compose configuration.

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://notes:notes@db:5432/notes
      - JWT_SECRET=${JWT_SECRET}
      - NODE_ENV=production
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=notes
      - POSTGRES_PASSWORD=notes
      - POSTGRES_DB=notes
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notes"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

What I fixed:

  1. The JWT_SECRET was hardcoded in docker-compose.yml. I changed it to use an environment variable.
  2. No .dockerignore file. Claude Code did not create one. I asked it to generate one to exclude node_modules, .env, and test files from the Docker context.
  3. Missing health check endpoint. The Dockerfile referenced /health but the Express app did not have that route. One more prompt to add it.

Security Review: What to Check Manually

AI-generated authentication code deserves extra scrutiny. Here is what I checked beyond the initial implementation:

JWT security patterns AI commonly gets wrong:

  • Storing secrets in code. Claude Code correctly used environment variables, but I have seen it hardcode secrets in other projects. Always check.
  • Weak token expiration. Some AI implementations set tokens to never expire. Check the expiresIn value.
  • Missing token validation. The middleware must verify the token signature, expiration, and payload structure. Claude Code handled all three.
  • No HTTPS enforcement. The Express app does not enforce HTTPS. In production, this should be handled by a reverse proxy (nginx).

What AI got right that surprised me:

  • Email case normalization
  • Generic error messages preventing user enumeration
  • bcrypt with appropriate rounds
  • Selecting only safe fields (no passwordHash in responses)

Time Tracking: The Full Build

StepTimeAI vs Manual
PRD writing8 minAI: 5 min, Review: 3 min
Project scaffold5 minAI: 3 min, Review: 2 min
Authentication20 minAI: 8 min, Review + fix: 12 min
Notes CRUD12 minAI: 6 min, Review + fix: 6 min
Database setup5 minAI: 3 min, Review: 2 min
Tests22 minAI: 10 min, Fix failures: 12 min
Deployment10 minAI: 5 min, Review + fix: 5 min
Security review8 minManual review
Total90 minAI: 40 min, Human: 50 min

Without AI, this project would take 6-10 hours. The scaffolding, boilerplate, and test generation are where AI saves the most time. The authentication review and test debugging are where human time is most valuable.

What AI Did Well

  • Boilerplate elimination. Express setup, Prisma configuration, Zod schemas, Docker files — all generated correctly on the first try.
  • Consistent patterns. Once the first controller was established, subsequent controllers followed the same pattern.
  • Test coverage. AI generated 18 integration tests covering success and error cases.
  • PRD as a foundation. Starting with a PRD gave every subsequent prompt a clear reference point.

Where AI Needed Help

  • Security nuances. The malformed token handling in the auth middleware needed manual attention.
  • Error consistency. Different parts of the code handled errors differently until I pointed it out.
  • Test infrastructure. Database cleanup, environment configuration, and test isolation all needed fixes.
  • Deployment details. Missing .dockerignore, hardcoded secrets, missing health endpoint.

Key Takeaways

  • Start with a PRD. It is the most effective way to give AI complete context for a large project.
  • Review auth code line by line. AI generates competent auth code, but security requires human verification.
  • Test infrastructure takes work. AI generates good test cases but struggles with test setup and teardown.
  • Deployment is where details matter. Docker configuration, environment variables, and health checks all needed manual fixes.
  • Budget 55% of time for review. The AI writes fast. Making it production-ready is the real work.

What’s Next?

This concludes the project-building section of the Vibe Coding series. You have built a CLI tool and a REST API using AI-assisted development. The patterns you learned — starting with specifications, implementing incrementally, reviewing at each step, and testing thoroughly — apply to any project you build with AI.


Part 16 of the Vibe Coding series.