Technology

TypeScript / Python API Tutorial

A hands-on tutorial that walks through building simple CRUD APIs with several popular frameworks. Each framework lives in its own sub-folder and is demonstrated through a shared Next.js UI.

20 min read
Published

Complete Tutorial Code

Follow along with the complete source code for this tutorial. Includes five framework implementations — Next.js, Express, NestJS, Flask, and FastAPI — all demonstrated through a shared Next.js UI.

View on GitHub

Introduction

A hands-on tutorial that walks through building simple CRUD APIs with several popular frameworks. Each framework lives in its own sub-folder and is demonstrated through a shared Next.js UI.

Framework Comparison

Not sure which framework to reach for? Here is a quick reference.

At a glance

NE

Next.js

Language: TypeScriptType: Full-stack React frameworkRouting: File-system (app/api/route.ts)Validation: ZodLearning curve: Low–MediumBest for: Full-stack web apps
OpinionatedBuilt-in DIAuto-generated docsAsync support
EX

Express

Language: TypeScriptType: Minimal Node.js web frameworkRouting: Imperative (app.get(...))Validation: ZodLearning curve: LowBest for: Simple APIs, microservices, prototypes
OpinionatedBuilt-in DIAuto-generated docsAsync support
NE

NestJS

Language: TypeScriptType: Structured Node.js frameworkRouting: Decorators (@Get())Validation: ZodLearning curve: Medium–HighBest for: Large, structured backend APIs
OpinionatedBuilt-in DIAuto-generated docsAsync support
FL

Flask

Language: PythonType: Minimal Python web frameworkRouting: Decorators (@app.route(...))Validation: PydanticLearning curve: LowBest for: Python APIs, data science backends
OpinionatedBuilt-in DIAuto-generated docsAsync support
FA

FastAPI

Language: PythonType: Modern async Python web frameworkRouting: Decorators (@app.get(...))Validation: Pydantic (built-in)Learning curve: Low–MediumBest for: High-performance Python APIs, ML serving
OpinionatedBuilt-in DIAuto-generated docsAsync support

Advantages and disadvantages

Next.js

✅ Advantages
  • Frontend and backend in one project — no CORS, no separate deploy
  • File-system routing is intuitive for small projects
  • Server Components reduce client-side JavaScript
  • Excellent developer experience (Turbopack, hot reload)
  • Vercel deployment is seamless
❌ Disadvantages
  • API routes are not a first-class REST framework — no built-in guards, interceptors, or DI
  • Tightly coupled to React — not suitable if you need a standalone API
  • File-system routing can become hard to navigate in very large projects
  • Harder to test API routes in isolation compared to a dedicated backend
  • Vendor lock-in risk if you rely on Vercel-specific features

Express

✅ Advantages
  • Extremely lightweight — almost no overhead
  • You control everything — no magic, no hidden behaviour
  • Massive ecosystem (passport, multer, helmet, …)
  • Easy to learn — the entire API surface is small
  • Great for microservices and simple REST APIs
❌ Disadvantages
  • No enforced structure — large projects can become messy quickly
  • No built-in DI, guards, interceptors, or modules
  • Error handling requires discipline (easy to forget next(err))
  • TypeScript support requires manual type annotations for req, res
  • No built-in validation — you must wire up Zod/Joi/etc. yourself

NestJS

✅ Advantages
  • Enforced structure scales well to large teams
  • Built-in DI makes testing and swapping dependencies easy
  • Guards, interceptors, pipes, and filters are first-class concepts
  • Excellent TypeScript integration
  • Built on Express — all Express middleware works
❌ Disadvantages
  • Steeper learning curve — decorators, DI, and modules take time to understand
  • More boilerplate for simple use cases
  • Requires experimentalDecorators and emitDecoratorMetadata in TypeScript
  • Heavier than Express — more dependencies, slower cold start
  • Angular-style architecture can feel unfamiliar to developers from other backgrounds

Flask

✅ Advantages
  • Extremely simple to get started — minimal boilerplate
  • Python ecosystem — great for data science, ML, and scripting
  • Pydantic provides powerful, Pythonic validation with type hints
  • UV makes dependency management fast and reproducible
  • Easy to integrate with SQLAlchemy, Celery, and other Python libraries
❌ Disadvantages
  • No enforced structure — large projects can become disorganised
  • No built-in async support (use Flask 2+ async views or switch to FastAPI)
  • No built-in DI, guards, or interceptors
  • Slower than Node.js for pure I/O-bound workloads
  • WSGI by default — requires extra setup for production (gunicorn, nginx)

FastAPI

✅ Advantages
  • Pydantic validation is built-in — request bodies are validated automatically
  • Auto-generates interactive API docs (Swagger UI at /docs, ReDoc at /redoc)
  • Native async/await support via ASGI (uvicorn)
  • Type hints drive both validation and editor autocompletion
  • Very high performance — comparable to Node.js frameworks
  • UV makes dependency management fast and reproducible
❌ Disadvantages
  • Async-first design can be confusing if you mix sync and async code
  • Smaller ecosystem than Flask — fewer third-party extensions
  • Requires uvicorn (or another ASGI server) — not WSGI-compatible
  • Dependency injection system is powerful but has a learning curve
  • Less mature than Flask for non-API use cases

When to use which

Building a React web app and want the API in the same projectNext.js
Deploying to Vercel or a similar platformNext.js
Building a simple REST API or microservice quicklyExpress
Prototyping or learning how HTTP servers workExpress
You want maximum control with minimum magicExpress
Building a large, team-maintained backend APINestJS
You need built-in DI, guards, and interceptorsNestJS
You are coming from an Angular backgroundNestJS
Your team primarily writes PythonFlask or FastAPI
You need to integrate with data science / ML librariesFlask or FastAPI
You need auto-generated API docs (Swagger / OpenAPI)FastAPI
You need high-performance async Python APIsFastAPI
You are serving an ML model or building a data APIFastAPI

Project Structure

ts-python-api-tutorial/
├── nextjs/          # Next.js app (UI + API route handlers)        → port 3000
├── express/         # Express.js REST API                          → port 3001
├── nestjs/          # NestJS REST API                              → port 3002
├── flask-api/       # Flask REST API (Python, managed with UV)     → port 3003
├── fastapi-api/     # FastAPI REST API (Python, managed with UV)   → port 3004
├── docs/            # Per-tab documentation
│   ├── nextjs.md
│   ├── express.md
│   ├── nestjs.md
│   ├── advanced-nestjs.md
│   ├── flask.md
│   ├── fastapi.md
│   └── concepts.md
└── scripts/
    └── start-servers.sh  # Starts all tutorial servers (Ctrl-C to stop all)

Quick Start

Prerequisites

  • Node.js ≥ 20.9 (required by Next.js 16)
  • npm — comes with Node.js
  • Python ≥ 3.9 and UV — for the Flask and FastAPI tabs

Install UV if you don't have it:

curl -LsSf https://astral.sh/uv/install.sh | sh

Install dependencies

# Next.js
cd nextjs && npm install

# Express
cd ../express && npm install

# NestJS
cd ../nestjs && npm install

# Flask (UV creates the virtual environment automatically on first run)
# No manual install step needed — UV handles it when you start the server.

# FastAPI — sync dependencies once to create the virtual environment
cd ../fastapi-api && uv sync

Run all servers

From the repo root:

./scripts/start-servers.sh

Then open http://localhost:3000 in your browser. The script starts every tutorial server in the background and kills them all when you press Ctrl-C.

💡 Tip — Start servers individually

cd nextjs && npm run devhttp://localhost:3000

cd express && npm run devhttp://localhost:3001

cd nestjs && npm run devhttp://localhost:3002

cd flask-api && uv run python main.pyhttp://localhost:3003

cd fastapi-api && uv run uvicorn main:app --host 0.0.0.0 --port 3004 --reloadhttp://localhost:3004

💡 Tip — FastAPI interactive docs

Once the FastAPI server is running you can explore the auto-generated API documentation at:

🟦 Tab 1 — Next.js CRUD

Port: 3000  |  Language: TypeScript  |  Validation: Zod

What is Next.js?

Next.js is a full-stack React framework built on top of Node.js. It lets you write both your frontend (React components) and your backend (API endpoints) in the same project, using the same language — TypeScript.

FeatureDescription
App RouterFile-system based routing — a file at app/page.tsx becomes /.
Route HandlersFiles named route.ts inside app/api/… become HTTP endpoints.
Server & Client ComponentsBy default, components run on the server. Add "use client" to opt into client-side React.
TurbopackThe built-in dev bundler — extremely fast hot-module replacement.
TypeScript firstFull TypeScript support out of the box, no extra config needed.

Key Features Demonstrated

This tab shows the simplest possible CRUD API using Next.js Route Handlers:

  • GET /api/items — return all items as JSON
  • POST /api/items — create a new item from a JSON body
  • GET /api/items/[id] — return a single item by ID
  • PUT /api/items/[id] — replace an item (full update)
  • PATCH /api/items/[id] — partially update an item
  • DELETE /api/items/[id] — remove an item

How the API Works

Collection route — nextjs/app/api/items/route.ts:

// GET /api/items
export async function GET() {
  return NextResponse.json(items);
}

// POST /api/items
export async function POST(request: NextRequest) {
  const body = await request.json();
  const { name, description } = createItemSchema.parse(body); // throws if invalid
  const newItem = { id: nextId++, name, description };
  items.push(newItem);
  return NextResponse.json(newItem, { status: 201 });
}

The folder name [id] creates a dynamic segment. Next.js passes the captured value through the second argument. In Next.js 15+, params is a Promise — you must await it.

Validation with Zod

Zod is a TypeScript-first schema validation library. Schemas are defined once and reused on both the server and the client — the same rules enforce correctness at the API boundary and provide user-friendly error messages in the UI.

// nextjs/app/lib/schemas.ts
import { z } from "zod";

export const createItemSchema = z.object({
  name: z.string().min(1, "Name is required"),
  description: z.string().optional().default(""),
});

export const patchItemSchema = z.object({
  name: z.string().min(1, "Name must not be empty").optional(),
  description: z.string().optional(),
});

Try It Yourself

# List all items
curl http://localhost:3000/api/items

# Create an item
curl -X POST http://localhost:3000/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"My Item","description":"Hello from curl"}'

# Update an item (replace 1 with the actual id)
curl -X PUT http://localhost:3000/api/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Updated Name","description":"Updated desc"}'

# Delete an item
curl -X DELETE http://localhost:3000/api/items/1

⚡ Tab 2 — Express CRUD

Port: 3001  |  Language: TypeScript  |  Validation: Zod

What is Express?

Express is the most widely-used Node.js web framework. Unlike Next.js, it is a pure backend library — it has no opinions about your frontend, no file-system routing, and no build step for the browser. You wire everything up yourself, which makes it a great way to understand what a web framework actually does under the hood.

Key Differences from Next.js

Next.js (Tab 1)Express (Tab 2)
Where the API livesSame process as the UISeparate server on port 3001
Routing styleFile-systemCode-based (app.get(...))
CORSNot needed (same origin)Required — browser blocks cross-origin requests
Body parsingBuilt-in (request.json())Middleware (app.use(express.json()))
Frontend fetch URL/api/items (relative)http://localhost:3001/api/items

How the API Works

The entire Express server lives in express/src/index.ts. Here is a guided tour:

1. Setup and middleware

import express from "express";
import cors from "cors";

const app = express();
const PORT = 3001;

// Allow requests from the Next.js dev server (port 3000)
app.use(cors({ origin: "http://localhost:3000" }));

// Parse JSON request bodies automatically
app.use(express.json());

2. Routes

// GET /api/items — list all items
app.get("/api/items", (_req, res) => {
  res.json(items);
});

// POST /api/items — create a new item
app.post("/api/items", (req, res) => {
  const { name, description } = createItemSchema.parse(req.body);
  const newItem = { id: nextId++, name, description };
  items.push(newItem);
  res.status(201).json(newItem);
});

// PUT /api/items/:id — full update
app.put("/api/items/:id", (req, res) => {
  const index = items.findIndex(i => i.id === Number(req.params.id));
  if (index === -1) { res.status(404).json({ error: "Item not found" }); return; }
  const { name, description } = updateItemSchema.parse(req.body);
  items[index] = { id: Number(req.params.id), name, description };
  res.json(items[index]);
});

// DELETE /api/items/:id
app.delete("/api/items/:id", (req, res) => {
  const index = items.findIndex(i => i.id === Number(req.params.id));
  if (index === -1) { res.status(404).json({ error: "Item not found" }); return; }
  const deleted = items.splice(index, 1)[0];
  res.json(deleted);
});

3. Global error handler

Express recognises a middleware with four parameters (err, req, res, next) as an error handler. Any unhandled error thrown inside a route handler is forwarded here automatically.

app.use((err, _req, res, _next) => {
  if (err instanceof ZodError) {
    res.status(400).json({ error: err.issues[0]?.message ?? "Validation error" });
    return;
  }
  console.error(err);
  res.status(500).json({ error: "Internal server error" });
});

Try It Yourself

# List all items
curl http://localhost:3001/api/items

# Create an item
curl -X POST http://localhost:3001/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"Express Item","description":"Created via curl"}'

# Update an item
curl -X PUT http://localhost:3001/api/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Updated","description":"New description"}'

# Delete an item
curl -X DELETE http://localhost:3001/api/items/1

🐦 Tab 3 — NestJS CRUD

Port: 3002  |  Language: TypeScript  |  Validation: Zod

What is NestJS?

NestJS is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. It is built on top of Express but adds a strong architectural layer inspired by Angular — using TypeScript decorators, dependency injection, and a module system to organise your code.

If Express is a blank canvas, NestJS is a structured blueprint. It enforces conventions that make large codebases easier to navigate and maintain.

Key Differences from Express

Express (Tab 2)NestJS (Tab 3)
Routing styleImperative (app.get(...))Decorators (@Get(), @Post())
Code organisationSingle file or ad-hocEnforced module/controller/service pattern
Parameter extractionreq.params.id, req.body@Param('id'), @Body() decorators
Error responsesManual res.status(404).json(...)Built-in exception classes (NotFoundException)
Dependency injectionNone (manual wiring)Built-in DI container

How the API Works

The NestJS server lives in nestjs/src/ and is split across three files.

1. Entry point — nestjs/src/main.ts

import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableCors({ origin: "http://localhost:3000" });
  await app.listen(3002);
}

bootstrap();

2. Controller — nestjs/src/items.controller.ts

Compare this to the Express routes — the logic is identical, but the declaration style is very different. @Param("id", ParseIntPipe) extracts the route parameter and converts it to a number automatically. NotFoundException automatically sends the correct HTTP status code — no manual res.status(404).json(...) needed.

@Controller("api/items")
export class ItemsController {

  @Get()
  findAll(): Item[] {
    return items;
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() body: unknown): Item {
    const { name, description } = createItemSchema.parse(body);
    const newItem = { id: nextId++, name, description };
    items.push(newItem);
    return newItem;
  }

  @Get(":id")
  findOne(@Param("id", ParseIntPipe) id: number): Item {
    const item = items.find(i => i.id === id);
    if (!item) throw new NotFoundException("Item not found");
    return item;
  }

  @Delete(":id")
  remove(@Param("id", ParseIntPipe) id: number): Item {
    const index = items.findIndex(i => i.id === id);
    if (index === -1) throw new NotFoundException("Item not found");
    return items.splice(index, 1)[0];
  }
}

3. TypeScript configuration

NestJS decorators require two extra compiler options in nestjs/tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Try It Yourself

# List all items
curl http://localhost:3002/api/items

# Create an item
curl -X POST http://localhost:3002/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"NestJS Item","description":"Created via curl"}'

# Update an item
curl -X PUT http://localhost:3002/api/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Updated","description":"New description"}'

# Delete an item
curl -X DELETE http://localhost:3002/api/items/1

🚀 Tab 4 — Advanced NestJS

Port: 3002  |  Language: TypeScript  |  Prefix: /api/advanced

This tab builds on the NestJS CRUD tab and introduces four advanced patterns that you will encounter in real-world NestJS applications. All of the code lives in nestjs/src/advanced.controller.ts and is served under the /api/advanced prefix.

What You Will Learn

PatternNestJS conceptEndpoint
Public endpoint@Public() custom decorator + ReflectorGET /api/advanced/public
API key guard@UseGuards(ApiKeyGuard)GET /api/advanced/guarded
Response wrapping@UseInterceptors(WrapResponseInterceptor)All /api/advanced/* routes
Dependency injection@Injectable() + module providersGET /api/advanced/di

Custom Guards

A Guard is a class that implements CanActivate. NestJS calls it before the route handler and lets it decide whether the request should proceed.

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const apiKey = request.headers["x-api-key"];
    if (apiKey !== "secret-key-123") {
      throw new BadRequestException(
        "Invalid or missing API key (use x-api-key: secret-key-123)"
      );
    }
    return true;
  }
}

// Apply the guard to an entire controller
@Controller("api/advanced")
@UseGuards(ApiKeyGuard)
export class AdvancedController { ... }

Custom Decorators and Metadata

Use @SetMetadata to attach metadata to a route handler, and the Reflector service to read it back inside a guard — for example, to opt certain routes out of authentication.

// Step 1 — Create a custom decorator
export const Public = () => SetMetadata("isPublic", true);

// Step 2 — Read the metadata inside the guard
canActivate(context: ExecutionContext): boolean {
  const isPublic = this.reflector.getAllAndOverride<boolean>("isPublic", [
    context.getHandler(),
    context.getClass(),
  ]);
  if (isPublic) return true; // skip the API key check
  // ... normal API key check
}

// Step 3 — Use the decorator on a route
@Get("public")
@Public()   // ← this route bypasses the guard
publicEndpoint() {
  return { message: "No API key needed!" };
}

Response Interceptors

An Interceptor wraps the execution of a route handler. This example wraps every response in a consistent envelope with success, data, and timestamp fields.

@Injectable()
export class WrapResponseInterceptor<T>
  implements NestInterceptor<T, { success: boolean; data: T; timestamp: string }>
{
  intercept(context: ExecutionContext, next: CallHandler<T>) {
    return next.handle().pipe(
      map((data) => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      }))
    );
  }
}

// Every response from this controller now looks like:
// { "success": true, "timestamp": "...", "data": { ...original... } }

Dependency Injection

This example demonstrates swappable implementations — program to an abstract contract and swap the concrete class in one place (the module) without touching the controller.

// Abstract contract
export abstract class GreetingService {
  abstract getRandomGreeting(): string;
}

// Two concrete implementations
@Injectable()
export class HumanGreetingService extends GreetingService {
  private readonly greetings = ["Hello", "Hola", "Bonjour", "Ciao", "こんにちは"];
  getRandomGreeting() { return this.greetings[Math.floor(Math.random() * this.greetings.length)]; }
}

// Bind in AppModule — change useClass here to swap implementations
@Module({
  providers: [
    { provide: GreetingService, useClass: HumanGreetingService },
  ],
})
export class AppModule {}

// Controller is unaware of the concrete class
@Controller("api/advanced")
export class AdvancedController {
  constructor(private greetingService: GreetingService) {}

  @Get("di")
  @Public()
  dependencyInjectionDemo() {
    return { greeting: this.greetingService.getRandomGreeting() };
  }
}

Try It Yourself

# Public endpoint — no API key needed
curl http://localhost:3002/api/advanced/public

# Guarded endpoint — correct key (response is wrapped by the interceptor)
curl http://localhost:3002/api/advanced/guarded \
  -H "x-api-key: secret-key-123"

# Guarded endpoint — wrong key → 400 error
curl http://localhost:3002/api/advanced/guarded \
  -H "x-api-key: wrong-key"

# DI demo — no API key needed (marked @Public())
curl http://localhost:3002/api/advanced/di

💡 Tip — Swap the DI implementation

Open nestjs/src/app.module.ts and change useClass: HumanGreetingService to useClass: FictionalGreetingService, then restart the NestJS server. The /api/advanced/di endpoint will now return sci-fi phrases — the controller code is unchanged.

🐍 Tab 5 — Flask CRUD

Port: 3003  |  Language: Python  |  Validation: Pydantic

What is Flask?

Flask is a lightweight Python web framework. Like Express in the Node.js world, Flask gives you routing and request handling with almost no boilerplate — you decide how to structure your project, which validation library to use, and how to handle errors.

This tab introduces the Python side of the tutorial. The API is functionally identical to the Express and NestJS tabs — the same CRUD operations, the same in-memory store, the same validation rules — but written in Python using Flask and Pydantic instead of TypeScript and Zod.

Key Differences from the TypeScript Frameworks

Express / NestJSFlask
LanguageTypeScriptPython
Package managernpmUV (uv add, uv run)
Validation libraryZodPydantic
Routingapp.get(...) / @Get()@app.route("/path", methods=["GET"])
Port3001 / 30023003

Project Setup with UV

The Flask project lives in flask-api/ and is managed with UV — a fast Python package manager written in Rust. UV automatically creates a .venv virtual environment and a uv.lock lockfile. You never need to activate the virtual environment manually — uv run handles it for you.

# From the flask-api/ directory
uv run python main.py

How the API Works

The entire Flask server lives in flask-api/main.py.

Routes

# GET /api/items — list all items
@app.route("/api/items", methods=["GET"])
def list_items():
    with _lock:
        return jsonify(list(_items.values())), 200

# POST /api/items — create a new item
@app.route("/api/items", methods=["POST"])
def create_item():
    global _next_id
    body = request.get_json(silent=True) or {}
    try:
        data = CreateItemSchema.model_validate(body)
    except ValidationError as exc:
        first_error = exc.errors()[0]
        return jsonify({"error": first_error["msg"]}), 422

    with _lock:
        item_id = _next_id
        _next_id += 1
        item = {"id": item_id, "name": data.name, "description": data.description}
        _items[item_id] = item
    return jsonify(item), 201

# DELETE /api/items/<id>
@app.route("/api/items/<int:item_id>", methods=["DELETE"])
def delete_item(item_id: int):
    with _lock:
        item = _items.pop(item_id, None)
    if item is None:
        return jsonify({"error": f"Item {item_id} not found"}), 404
    return jsonify({"message": f"Item {item_id} deleted"}), 200

Validation with Pydantic

Pydantic is Python's answer to Zod. It uses standard Python type hints to define schemas and validates data at runtime.

from pydantic import BaseModel, ValidationError, field_validator
from typing import Optional

class CreateItemSchema(BaseModel):
    name: str
    description: Optional[str] = ""

    @field_validator("name")
    @classmethod
    def name_must_not_be_empty(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("Name is required and cannot be empty")
        if len(v) < 2:
            raise ValueError("Name must be at least 2 characters")
        return v

Try It Yourself

# List all items
curl http://localhost:3003/api/items

# Create an item
curl -X POST http://localhost:3003/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"Flask Item","description":"Created via curl"}'

# Try an invalid body — Pydantic will reject it
curl -X POST http://localhost:3003/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"x"}'

# Update an item (replace 1 with the actual id)
curl -X PUT http://localhost:3003/api/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Updated Flask Item","description":"New description"}'

# Delete an item
curl -X DELETE http://localhost:3003/api/items/1

⚡ Tab 6 — FastAPI CRUD

Port: 3004  |  Language: Python  |  Validation: Pydantic (built-in)

What is FastAPI?

FastAPI is a modern, high-performance Python web framework built on top of Starlette (ASGI) and Pydantic. It is designed to make building APIs fast to write, easy to read, and correct by default.

This tab builds the same CRUD API as the Flask tab — the same endpoints, the same in-memory store, the same validation rules — but using FastAPI instead of Flask. The goal is to show how the two Python frameworks compare in practice.

FastAPI vs Flask — Key Differences

FlaskFastAPI
WSGI vs ASGIWSGI (synchronous by default)ASGI (async-first, runs on uvicorn)
ValidationManual — call Schema.model_validate(body) and catch ValidationErrorAutomatic — declare a Pydantic model as a parameter
CORSManual after_request hookCORSMiddleware from Starlette
Error responsesjsonify({"error": "..."}, 422)HTTPException(status_code=404, detail="...")
API docs❌ None built-in✅ Swagger UI at /docs, ReDoc at /redoc
Port30033004

How the API Works

The entire FastAPI server lives in fastapi-api/main.py.

Setup and CORS

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# CORSMiddleware handles preflight requests automatically
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

Routes — automatic validation via Pydantic parameters

The biggest practical difference from Flask: declare the Pydantic model as a function parameter and FastAPI validates automatically. If validation fails, FastAPI returns a 422 response — no try/except needed.

# GET /api/items — list all items
@app.get("/api/items")
def list_items():
    with _lock:
        return list(_items.values())

# POST /api/items — FastAPI validates body: CreateItemSchema automatically
@app.post("/api/items", status_code=201)
def create_item(body: CreateItemSchema):
    global _next_id
    with _lock:
        item_id = _next_id
        _next_id += 1
        item = {"id": item_id, "name": body.name, "description": body.description}
        _items[item_id] = item
    return item

# GET /api/items/{item_id} — path parameter typed as int
@app.get("/api/items/{item_id}")
def get_item(item_id: int):
    with _lock:
        item = _items.get(item_id)
    if item is None:
        raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
    return item

# DELETE /api/items/{item_id}
@app.delete("/api/items/{item_id}")
def delete_item(item_id: int):
    with _lock:
        item = _items.pop(item_id, None)
    if item is None:
        raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
    return {"message": f"Item {item_id} deleted"}

Auto-Generated API Docs

One of FastAPI's standout features is automatic interactive documentation. FastAPI generates these from your route decorators, Pydantic schemas, and type hints — no extra configuration needed.

📖 Interactive API Documentation

Flask vs FastAPI — How Validation is Invoked

# Flask — you call model_validate() manually and catch the exception
body = request.get_json(silent=True) or {}
try:
    data = CreateItemSchema.model_validate(body)
except ValidationError as exc:
    first_error = exc.errors()[0]
    return jsonify({"error": first_error["msg"]}), 422

# FastAPI — declare the schema as a parameter; FastAPI does the rest
@app.post("/api/items", status_code=201)
def create_item(body: CreateItemSchema):   # ← FastAPI validates automatically
    ...

Try It Yourself

# List all items
curl http://localhost:3004/api/items

# Create an item
curl -X POST http://localhost:3004/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"FastAPI Item","description":"Created via curl"}'

# Try an invalid body — Pydantic will reject it
curl -X POST http://localhost:3004/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"x"}'

# Update an item (replace 1 with the actual id)
curl -X PUT http://localhost:3004/api/items/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Updated FastAPI Item","description":"New description"}'

# Delete an item
curl -X DELETE http://localhost:3004/api/items/1

📚 Core Concepts Reference

New to web APIs, or encountering an unfamiliar term in one of the tab docs? This reference explains the foundational ideas that appear across all five frameworks. If you encounter an unfamiliar term, this is the place to look it up.

HTTP and REST APIs

HTTP methods, status codes, and REST conventions. All five frameworks implement the same REST API contract — the same URLs, the same methods, the same JSON shapes.

CRUD

Create, Read, Update, Delete — the four fundamental operations. Maps directly to POST, GET, PUT/PATCH, DELETE.

Middleware

Functions that sit between the incoming request and your route handler. Think of checkpoints at an airport — each one can inspect, modify, or reject the request.

Routing

How each framework maps URLs to handler functions. Next.js uses file-system routing; Express uses imperative calls; NestJS uses decorators; Flask and FastAPI use decorators too.

Validation and Schemas

Zod (TypeScript) and Pydantic (Python) compared. Both catch bad input before it reaches your business logic.

Dependency Injection

A pattern where a class declares what it needs and the framework provides it automatically. First-class in NestJS; manual in Express, Flask, and FastAPI.

Decorators

@Something annotations in TypeScript (NestJS) and Python (Flask, FastAPI). They attach behaviour or metadata to code without changing the code itself.

Modules

A NestJS concept — a class decorated with @Module() that groups related controllers and services together.

Guards

A NestJS concept — a class that implements CanActivate. Runs before the route handler and decides whether the request should proceed.

Interceptors

A NestJS concept — wraps the execution of a route handler. Can transform the response, add headers, log timing, or handle errors.

CORS

Cross-Origin Resource Sharing — a browser security mechanism. Each framework handles it differently: Next.js needs none; Express uses the cors package; NestJS uses enableCors(); Flask uses an after_request hook; FastAPI uses CORSMiddleware.

WSGI vs ASGI

Python server interfaces. Flask is WSGI (synchronous); FastAPI is ASGI (async-first, runs on uvicorn). For I/O-bound workloads, ASGI can significantly improve throughput.

Sync vs Async

Synchronous code runs one step at a time. Async code can pause while waiting for I/O and let other code run. All five frameworks support async/await.

In-Memory Store

All five APIs store data in memory (a plain array or dictionary) rather than a database. Data resets when the server restarts — the focus is on the API layer, not persistence.

OpenAPI / Swagger

A standard, machine-readable format for describing REST APIs. FastAPI generates Swagger UI and ReDoc automatically. The other frameworks require additional libraries.

Conclusion

This tutorial demonstrates that there is no single "right" framework for building REST APIs. Each of the five frameworks covered here represents a different philosophy — from the minimal flexibility of Express and Flask, to the structured power of NestJS, to the full-stack convenience of Next.js, to the async-first performance of FastAPI.

The key insight is that the concepts of web APIs are universal — routing, validation, middleware, error handling — only the syntax and conventions change. By building the same CRUD API in all five frameworks side by side, you can see exactly where each framework adds value and where it adds complexity.

About the Author

Wayne Cheng is the founder and AI app developer at Audoir, LLC. Prior to founding Audoir, he worked as a hardware design engineer for Silicon Valley startups and an audio engineer for creative organizations. He holds an MSEE from UC Davis and a Music Technology degree from Foothill College.

Further Exploration

Explore the complete tutorial repository and experiment with extending the examples. Consider replacing the in-memory store with a real database, adding authentication, or deploying one of the backends to a cloud provider to deepen your understanding of API development.

For more AI-powered development tools and tutorials, visit Audoir .