Tutorial API Build

Build a REST API from Spec to Deployment

Design the API before writing it. Build it with AI. Test it. Deploy it. Every step, from OpenAPI spec to production URL.


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:

TypeScript Express SQLite Zod Vitest

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

You

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:

You

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:

Design → Critique → Revise

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

You

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

You

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

You

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

You

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.

⚠ Review the Search Query

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

You

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

You

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

You

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

You

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);
  });
});
Tests As Specification

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

You

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:

You

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.

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

Back to Home