What We're Building
A bookmark manager API — a service for saving, organizing, and searching links. Simple enough to build in one guide, but complex enough to have real-world patterns:
- User registration and JWT authentication
- Full CRUD for bookmarks (create, read, update, delete)
- Tagging system (many-to-many relationship)
- Search by title, URL, and tags
- Pagination on list endpoints
- Input validation on every endpoint
- Integration tests for every route
- Docker build and deployment
Part 1: Start With the Spec
Most developers start an API project by writing code. That's backwards. The spec comes first — it forces you to think through every endpoint, every request shape, every response format, and every error case before writing a single line of implementation.
Designing the API With AI
I'm building a bookmark manager API. Users can save URLs with titles, descriptions, and tags. They can search their bookmarks, filter by tags, and paginate results.
Design the REST API. For each endpoint, specify: method, path, request body/params, success response, error responses, and authentication requirements.
Follow these conventions:
- RESTful resource naming (plural nouns)
- Consistent response envelope: { data: T } for single items, { data: T[], meta: { total, page, pageSize } } for lists
- Error format: { error: string, code: string }
- JWT auth via Authorization header
- IDs are UUIDs
AI produces a complete endpoint list. Here's what it should look like:
Authentication
POST /api/auth/register Register a new user
POST /api/auth/login Login, receive JWT
GET /api/auth/me Get current user profile
Bookmarks
POST /api/bookmarks Create a bookmark
GET /api/bookmarks List bookmarks (paginated, filterable)
GET /api/bookmarks/:id Get a single bookmark
PATCH /api/bookmarks/:id Update a bookmark
DELETE /api/bookmarks/:id Delete a bookmark
Tags
GET /api/tags List all tags for current user
GET /api/tags/:name Get bookmarks by tag
The Critique Step
Before accepting this design, ask AI to attack it:
Critique this API design. Look for:
1. Missing endpoints that users would expect
2. Inconsistencies in naming or response formats
3. Missing query parameters (search, filter, sort, pagination)
4. Security concerns
5. Edge cases the design doesn't address
Typical findings from the critique:
- Missing: bulk operations — No way to delete multiple bookmarks. Add
DELETE /api/bookmarkswith a body of IDs. - Missing: search parameters — The GET /api/bookmarks endpoint needs query params:
?q=search&tag=name&sort=created_at&order=desc&page=1&pageSize=20 - Missing: duplicate detection — What happens when a user saves the same URL twice? The API should either reject it or update the existing bookmark.
- Security: rate limiting — Auth endpoints need rate limiting to prevent brute force.
- Inconsistency: tag endpoints — Tags are accessed both through bookmarks and through their own endpoint. Clarify that
GET /api/tags/:namereturns bookmarks, not the tag itself.
This loop is the most valuable use of AI in API development. Ten minutes of design critique saves hours of implementation rework. Every issue found at the spec level is an issue that never becomes a bug.
Defining the Types
Based on the finalized API design, generate TypeScript types for all request inputs, response bodies, and database models. These types will be the contract between all layers of the application.
// ── Database Models ──
interface User {
id: string;
email: string;
passwordHash: string;
createdAt: string;
}
interface Bookmark {
id: string;
userId: string;
url: string;
title: string;
description: string | null;
createdAt: string;
updatedAt: string;
}
interface Tag {
id: string;
name: string;
userId: string;
}
interface BookmarkTag {
bookmarkId: string;
tagId: string;
}
// ── API Inputs ──
interface CreateBookmarkInput {
url: string;
title: string;
description?: string;
tags?: string[]; // tag names, created if they don't exist
}
interface UpdateBookmarkInput {
url?: string;
title?: string;
description?: string | null;
tags?: string[];
}
interface BookmarkQuery {
q?: string; // search title and URL
tag?: string; // filter by tag name
sort?: 'created_at' | 'title' | 'updated_at';
order?: 'asc' | 'desc';
page?: number;
pageSize?: number; // default 20, max 100
}
// ── API Responses ──
interface BookmarkResponse extends Omit<Bookmark, 'userId'> {
tags: string[];
}
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
These types drive everything that follows. Every route handler, every validation schema, every database query, and every test will reference them. Define them once, use them everywhere.
Part 2: Project Setup
Set up an Express + TypeScript project for the bookmark manager API.
Here are my types: [paste types.ts]
Requirements:
- TypeScript in strict mode
- Express with JSON body parsing
- better-sqlite3 for the database
- Zod for input validation
- bcryptjs for password hashing
- jsonwebtoken for JWT
- vitest + supertest for testing
- Scripts: dev (with tsx watch), build, start, test
- Environment variables: PORT, JWT_SECRET, DATABASE_URL
Give me: package.json, tsconfig.json, src/index.ts (entry point with middleware), and the folder structure.
bookmark-api/
├── src/
│ ├── index.ts # Express app setup, middleware, routes
│ ├── config.ts # Environment variables with defaults
│ ├── types.ts # Shared types (from spec)
│ ├── db/
│ │ ├── database.ts # SQLite connection and setup
│ │ └── migrations.ts # Schema creation
│ ├── repos/
│ │ ├── userRepo.ts
│ │ ├── bookmarkRepo.ts
│ │ └── tagRepo.ts
│ ├── routes/
│ │ ├── auth.ts
│ │ ├── bookmarks.ts
│ │ └── tags.ts
│ ├── middleware/
│ │ ├── auth.ts # JWT verification
│ │ ├── validate.ts # Zod validation middleware
│ │ └── errorHandler.ts
│ └── utils/
│ └── errors.ts # AppError class and factories
├── tests/
│ ├── helpers.ts # Test utilities, factories
│ ├── auth.test.ts
│ ├── bookmarks.test.ts
│ └── tags.test.ts
├── package.json
├── tsconfig.json
└── .env.example
Pro Tip: Paste the Structure Back
After generating the project structure, paste it back as context in subsequent prompts. "Here's my project structure: [paste tree]. Now implement the bookmarkRepo.ts." AI generates code that imports from the correct paths and follows the established structure.
Part 3: Database & Repositories
Schema Design
Create the SQLite schema for the bookmark manager. Here are the types:
[paste types.ts]
Requirements:
- UUIDs as text primary keys
- Foreign keys enforced
- Timestamps as ISO 8601 text
- Unique constraint on (userId, url) to prevent duplicate bookmarks
- Indexes on: bookmarks.userId, bookmarks.createdAt, tags.userId, tags.name
- Cascade delete: deleting a bookmark removes its tag associations
- Cascade delete: deleting a user removes all their bookmarks and tags
export function migrate(db: Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, url)
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, name)
);
CREATE TABLE IF NOT EXISTS bookmark_tags (
bookmark_id TEXT NOT NULL REFERENCES bookmarks(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (bookmark_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON bookmarks(user_id);
CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at);
CREATE INDEX IF NOT EXISTS idx_tags_user ON tags(user_id);
CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(user_id, name);
`);
}
Repository Implementation
Implement the bookmark repository.
Types: [paste types.ts]
Schema: [paste migration above]
interface BookmarkRepo {
create(userId: string, input: CreateBookmarkInput): BookmarkResponse;
findById(userId: string, id: string): BookmarkResponse | null;
findByUser(userId: string, query: BookmarkQuery): PaginatedResponse<BookmarkResponse>;
update(userId: string, id: string, input: UpdateBookmarkInput): BookmarkResponse | null;
delete(userId: string, id: string): boolean;
}
Rules:
- All queries parameterized
- SELECT specifies columns (no SELECT *)
- findByUser supports search (LIKE on title and url), tag filtering (JOIN), sorting, and pagination
- create and update handle tags: find-or-create tag by name, manage bookmark_tags associations
- userId is always checked — users can only access their own bookmarks
- Return BookmarkResponse (with tags array), not raw Bookmark
The critical constraints: "userId is always checked" prevents the IDOR (Insecure Direct Object Reference) vulnerability where users can access each other's data. "Return BookmarkResponse with tags" ensures the API response includes the tag names, not just the raw bookmark row.
The findByUser method builds a dynamic SQL query from user input (search terms, sort column, pagination). This is the highest-risk function in the repository. Review it line by line for SQL injection — especially the sort column, which can't be parameterized and must be validated against an allowlist.
Part 4: Routes & Validation
Zod Schemas From the Types
Create Zod validation schemas for all API inputs based on these types:
[paste CreateBookmarkInput, UpdateBookmarkInput, BookmarkQuery]
Rules:
- URL must be a valid URL format
- Title: 1-200 characters
- Description: max 2000 characters
- Tags: array of 1-50 character strings, max 10 tags, lowercased and trimmed
- Page: positive integer, default 1
- PageSize: 1-100, default 20
- Sort: only allowed values from the type
- Each schema exports the Zod object and the inferred TypeScript type
import { z } from 'zod';
export const createBookmarkSchema = z.object({
url: z.string().url('Must be a valid URL'),
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
tags: z
.array(z.string().min(1).max(50).transform(s => s.toLowerCase().trim()))
.max(10, 'Maximum 10 tags')
.optional()
.default([]),
});
export const updateBookmarkSchema = z.object({
url: z.string().url().optional(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
tags: z
.array(z.string().min(1).max(50).transform(s => s.toLowerCase().trim()))
.max(10)
.optional(),
});
export const bookmarkQuerySchema = z.object({
q: z.string().max(200).optional(),
tag: z.string().max(50).optional(),
sort: z.enum(['created_at', 'title', 'updated_at']).default('created_at'),
order: z.enum(['asc', 'desc']).default('desc'),
page: z.coerce.number().int().positive().default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
Notice the .transform(s => s.toLowerCase().trim()) on tags — this normalizes tag input at the validation layer so the rest of the codebase never has to worry about case sensitivity or whitespace.
Validation Middleware
import { ZodSchema, ZodError } from 'zod';
import { Request, Response, NextFunction } from 'express';
export function validateBody(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = formatZodErrors(result.error);
return res.status(400).json({ error: 'Validation failed', code: 'VALIDATION_ERROR', details: errors });
}
req.body = result.data; // Replace with parsed + transformed data
next();
};
}
export function validateQuery(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
const errors = formatZodErrors(result.error);
return res.status(400).json({ error: 'Validation failed', code: 'VALIDATION_ERROR', details: errors });
}
req.query = result.data;
next();
};
}
function formatZodErrors(error: ZodError): Record<string, string> {
const errors: Record<string, string> = {};
for (const issue of error.issues) {
const path = issue.path.join('.');
errors[path] = issue.message;
}
return errors;
}
Route Implementation
Implement the bookmark routes.
Types: [paste types.ts]
Schemas: [paste schemas from above]
Validation middleware: [paste validate.ts]
Auth middleware extracts userId: req.user.id
Repository: [paste bookmarkRepo interface]
Routes:
- POST /api/bookmarks — validate body, create, return 201 with { data: bookmark }
- GET /api/bookmarks — validate query, list with pagination, return { data, meta }
- GET /api/bookmarks/:id — find by id, return 404 if not found
- PATCH /api/bookmarks/:id — validate body, update, return 404 if not found
- DELETE /api/bookmarks/:id — delete, return 204 on success, 404 if not found
Use asyncHandler wrapper for all routes. Use the validation middleware in the route chain.
import { Router } from 'express';
import { authenticate } from '../middleware/auth';
import { validateBody, validateQuery } from '../middleware/validate';
import { createBookmarkSchema, updateBookmarkSchema, bookmarkQuerySchema } from '../schemas';
import { bookmarkRepo } from '../repos/bookmarkRepo';
import { asyncHandler } from '../utils/errors';
const router = Router();
// All bookmark routes require authentication
router.use(authenticate);
router.post('/',
validateBody(createBookmarkSchema),
asyncHandler(async (req, res) => {
const bookmark = bookmarkRepo.create(req.user.id, req.body);
res.status(201).json({ data: bookmark });
})
);
router.get('/',
validateQuery(bookmarkQuerySchema),
asyncHandler(async (req, res) => {
const result = bookmarkRepo.findByUser(req.user.id, req.query);
res.json(result);
})
);
// ... remaining routes follow the same pattern
The middleware chain reads like a sentence: authenticate → validate → handle. Every route follows this pattern. AI generates consistent routes because the pattern is established and the middleware is provided as context.
Part 5: Testing
Integration tests verify the entire request-response cycle: HTTP request → middleware → route handler → repository → database → response. They catch issues that unit tests miss.
Test Infrastructure
Create test helpers for the bookmark API.
Here's the Express app: [paste src/index.ts]
I need:
1. A createTestApp() function that creates a fresh Express app with an in-memory SQLite database for each test file
2. A createTestUser(app) function that registers a user and returns { user, token }
3. A createTestBookmark(app, token, overrides?) factory that creates a bookmark with defaults
4. An authHeader(token) helper that returns the Authorization header object
5. Sensible defaults for all test data
Route Tests
Write integration tests for the bookmark routes using Vitest and supertest.
Test helpers: [paste helpers.ts]
Route implementation: [paste routes/bookmarks.ts]
Schemas: [paste schemas.ts]
Test these cases:
POST /api/bookmarks: success with all fields, success with only required fields, reject missing title, reject invalid URL, reject duplicate URL for same user, allow same URL for different users, reject without auth token
GET /api/bookmarks: return user's bookmarks only (not other users'), pagination (page 1 and page 2), search by title, filter by tag, sort by created_at and title, empty result returns empty array with correct meta
PATCH /api/bookmarks/:id: update title only, update tags (replaces all tags), reject updating another user's bookmark (403), reject non-existent bookmark (404)
DELETE /api/bookmarks/:id: success returns 204, reject deleting another user's bookmark, reject non-existent bookmark, verify tags are cleaned up after deletion
The test case list is the key. Without it, AI generates 5-6 tests that cover the happy paths. With it, you get 20+ tests that cover the edge cases and security boundaries that actually matter.
describe('POST /api/bookmarks', () => {
it('creates a bookmark with all fields', async () => {
const { token } = await createTestUser(app);
const res = await request(app)
.post('/api/bookmarks')
.set(authHeader(token))
.send({
url: 'https://example.com',
title: 'Example Site',
description: 'A test bookmark',
tags: ['testing', 'example'],
});
expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({
url: 'https://example.com',
title: 'Example Site',
description: 'A test bookmark',
tags: ['testing', 'example'],
});
expect(res.body.data.id).toBeDefined();
expect(res.body.data.createdAt).toBeDefined();
});
it('rejects duplicate URL for the same user', async () => {
const { token } = await createTestUser(app);
await createTestBookmark(app, token, { url: 'https://example.com' });
const res = await request(app)
.post('/api/bookmarks')
.set(authHeader(token))
.send({ url: 'https://example.com', title: 'Duplicate' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('BOOKMARK_DUPLICATE_URL');
});
it('rejects request without auth token', async () => {
const res = await request(app)
.post('/api/bookmarks')
.send({ url: 'https://example.com', title: 'No Auth' });
expect(res.status).toBe(401);
});
});
These tests are the executable version of your API spec. Every behavior you specified in Part 1 has a corresponding test. If a test fails, either the implementation is wrong or the spec changed — and you need to know which.
Part 6: Documentation & Deployment
Auto-Generated API Docs
Generate an OpenAPI 3.0 spec in YAML for the bookmark API based on these routes, schemas, and response types:
[paste routes, Zod schemas, and types]
Include: all endpoints, request bodies, query parameters, response schemas for success and error, JWT security scheme, and example values for each field.
The OpenAPI spec can be served as a JSON endpoint (GET /api/docs/openapi.json) and rendered with Swagger UI or Redoc. Your API is now self-documenting — the spec and the implementation stay in sync because they were generated from the same types.
Docker Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
# SQLite data directory
RUN mkdir -p /data
ENV DATABASE_URL=/data/bookmarks.db
CMD ["node", "dist/index.js"]
services:
api:
build: .
ports:
- "3000:3000"
environment:
- JWT_SECRET=${JWT_SECRET}
- DATABASE_URL=/data/bookmarks.db
volumes:
- db-data:/data
volumes:
db-data:
Environment Configuration
# Server
PORT=3000
NODE_ENV=development
# Security
JWT_SECRET=change-this-to-a-random-string-in-production
# Database
DATABASE_URL=./data/bookmarks.db
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
databaseUrl: process.env.DATABASE_URL || './data/bookmarks.db',
isProduction: process.env.NODE_ENV === 'production',
} as const;
// Validate required config in production
if (config.isProduction) {
if (config.jwtSecret === 'dev-secret-change-in-production') {
throw new Error('JWT_SECRET must be set in production');
}
}
Deployment
For a SQLite-based API, the simplest production deployment:
I need to deploy this Express + SQLite API to production. The database file needs to persist. Give me deployment instructions for Railway with a persistent volume.
The key requirement: SQLite needs a persistent filesystem. Railway, Fly.io, and Render all support persistent volumes. Platforms that use ephemeral filesystems (like basic Heroku dynos) won't work for SQLite — you'd need to switch to PostgreSQL.
- Railway — Add a volume mounted at
/data. Set environment variables in the dashboard. Deploy from GitHub. - Fly.io — Create a volume with
fly volumes create. Mount it infly.toml. Deploy withfly deploy. - VPS (DigitalOcean, Linode) — Docker Compose on the server. Data persists on disk. Most control, most setup.
Pro Tip: Test the Docker Build Locally First
Run docker compose up --build and test every endpoint with curl before deploying. The most common deployment failures are missing environment variables, wrong file paths in the Docker build, and SQLite permissions issues. Catch them locally where debugging is fast.
From Spec to Production
You started with a conversation about what the API should do. You ended with a tested, documented, deployed service. The AI accelerated every step — but the discipline of spec first, types second, implementation third is what made the result production-quality.
API Tutorial — Summary
- Spec first — Design every endpoint before writing code. Critique the design for missing endpoints, security issues, and edge cases.
- Types as contracts — Define TypeScript types from the spec. Every layer references them.
- Validation at the boundary — Zod schemas validate and transform input. Tag normalization, pagination defaults, URL validation — all handled at entry.
- Middleware chain — authenticate → validate → handle. Every route follows the same pattern.
- Security by default — Parameterized queries, userId checks on every operation, no SELECT *, rate limiting on auth.
- Tests as specification — Integration tests verify the full request-response cycle. List test cases explicitly in the prompt.
- Self-documenting — OpenAPI spec generated from the same types and schemas as the implementation.
- Deployable — Docker build, environment configuration, persistent volume for SQLite.