Skip to main content

Tutorial 2: Todo List API

In this tutorial, you'll build a complete CRUD (Create, Read, Update, Delete) API for managing a todo list. This introduces more advanced concepts like request validation, dynamic routes, and error handling.

What You'll Build​

A todo list API with the following endpoints:

  • GET /api/todos - List all todos
  • POST /api/todos - Create a new todo
  • GET /api/todos/[id] - Get a specific todo
  • PUT /api/todos/[id] - Update a todo
  • DELETE /api/todos/[id] - Delete a todo

What You'll Learn​

  • Multiple HTTP methods (GET, POST, PUT, DELETE)
  • Dynamic routes with parameters
  • Zod schema validation
  • Error handling and status codes
  • In-memory data storage
  • Request body parsing

Prerequisites​

Complete the Hello World API tutorial first. You should understand:

  • Basic BurgerAPI setup
  • File-based routing
  • Creating route handlers

Step 1: Set Up Your Project​

You can either continue from the Hello World API tutorial or create a fresh project using the CLI:

# Option A: Continue from Hello World
# (reuse the same project and add todos on top)

# Option B: Create a new project
burger-api create todo-api
cd todo-api

# Install Zod for validation if it's not already present
bun add zod

If you used burger-api create, it already wired up a basic Burger instance and a burger.config.ts. We will customize the server entry a bit for this tutorial.

For more about these options, see Configuration and CLI Tool.

Step 2: Configure Your Server​

Update or create your index.ts file:

index.ts
import { Burger } from "burger-api";

const burger = new Burger({
apiDir: "api",
title: "Todo List API",
version: "1.0.0",
description: "A CRUD API for managing todos",
debug: true,
});

burger.serve(4000, () => {
console.log("🚀 Todo API running at http://localhost:4000");
console.log("📚 API docs at http://localhost:4000/docs");
});

Step 3: Create the Todo Model​

First, let's define what a todo looks like. Create a types.ts file:

types.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
createdAt: string;
updatedAt: string;
}

export interface CreateTodoRequest {
title: string;
completed?: boolean;
}

export interface UpdateTodoRequest {
title?: string;
completed?: boolean;
}

Step 4: Set Up In-Memory Storage​

Create a simple in-memory database:

database.ts
import type { Todo } from "./types";

// In-memory storage (in a real app, you'd use a database)
let todos: Todo[] = [
{
id: 1,
title: "Learn BurgerAPI",
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 2,
title: "Build a todo API",
completed: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];

let nextId = 3;

export const todoDatabase = {
// Get all todos
getAll(): Todo[] {
return todos;
},

// Get a todo by ID
getById(id: number): Todo | undefined {
return todos.find(todo => todo.id === id);
},

// Create a new todo
create(title: string, completed = false): Todo {
const now = new Date().toISOString();
const newTodo: Todo = {
id: nextId++,
title,
completed,
createdAt: now,
updatedAt: now,
};
todos.push(newTodo);
return newTodo;
},

// Update a todo
update(id: number, updates: Partial<Pick<Todo, 'title' | 'completed'>>): Todo | null {
const todo = todos.find(t => t.id === id);
if (!todo) return null;

Object.assign(todo, updates, {
updatedAt: new Date().toISOString(),
});
return todo;
},

// Delete a todo
delete(id: number): boolean {
const index = todos.findIndex(todo => todo.id === id);
if (index === -1) return false;

todos.splice(index, 1);
return true;
},
};

Step 5: Create Validation Schemas​

Create validation schemas using Zod:

schemas.ts
import { z } from "zod";

export const createTodoSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title too long"),
completed: z.boolean().optional().default(false),
});

export const updateTodoSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title too long").optional(),
completed: z.boolean().optional(),
}).refine(
(data) => Object.keys(data).length > 0,
"At least one field must be provided"
);

Step 6: Create the Todos Collection Route​

Create the main todos endpoint:

api/todos/route.ts
import type { BurgerRequest } from "burger-api";
import { todoDatabase } from "../../database";
import { createTodoSchema } from "../../schemas";

// GET /api/todos - List all todos
export function GET(req: BurgerRequest) {
const todos = todoDatabase.getAll();
return Response.json({
todos,
count: todos.length,
});
}

// POST /api/todos - Create a new todo
export async function POST(req: BurgerRequest) {
try {
const body = await req.json();

// Validate the request body
const validationResult = createTodoSchema.safeParse(body);

if (!validationResult.success) {
return Response.json(
{
error: "Validation failed",
details: validationResult.error.format()
},
{ status: 400 }
);
}

const { title, completed } = validationResult.data;
const newTodo = todoDatabase.create(title, completed);

return Response.json(newTodo, { status: 201 });
} catch (error) {
return Response.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
}
What's Happening?
  • GET returns all todos with a count
  • POST validates the request body using Zod
  • We return appropriate HTTP status codes (201 for created, 400 for errors)
  • Error responses include helpful details

Step 7: Create the Individual Todo Route​

Create the dynamic route for individual todos:

api/todos/[id]/route.ts
import type { BurgerRequest } from "burger-api";
import { todoDatabase } from "../../../database";
import { updateTodoSchema } from "../../../schemas";

// GET /api/todos/[id] - Get a specific todo
export function GET(req: BurgerRequest) {
const id = parseInt(req.params.id);

if (isNaN(id)) {
return Response.json(
{ error: "Invalid todo ID" },
{ status: 400 }
);
}

const todo = todoDatabase.getById(id);

if (!todo) {
return Response.json(
{ error: "Todo not found" },
{ status: 404 }
);
}

return Response.json(todo);
}

// PUT /api/todos/[id] - Update a todo
export async function PUT(req: BurgerRequest) {
try {
const id = parseInt(req.params.id);

if (isNaN(id)) {
return Response.json(
{ error: "Invalid todo ID" },
{ status: 400 }
);
}

const body = await req.json();

// Validate the request body
const validationResult = updateTodoSchema.safeParse(body);

if (!validationResult.success) {
return Response.json(
{
error: "Validation failed",
details: validationResult.error.format()
},
{ status: 400 }
);
}

const updatedTodo = todoDatabase.update(id, validationResult.data);

if (!updatedTodo) {
return Response.json(
{ error: "Todo not found" },
{ status: 404 }
);
}

return Response.json(updatedTodo);
} catch (error) {
return Response.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
}

// DELETE /api/todos/[id] - Delete a todo
export function DELETE(req: BurgerRequest) {
const id = parseInt(req.params.id);

if (isNaN(id)) {
return Response.json(
{ error: "Invalid todo ID" },
{ status: 400 }
);
}

const deleted = todoDatabase.delete(id);

if (!deleted) {
return Response.json(
{ error: "Todo not found" },
{ status: 404 }
);
}

return new Response(null, { status: 204 });
}
Understanding Dynamic Routes

The [id] folder creates a dynamic route parameter. When someone visits /api/todos/123, the value 123 becomes available as req.params.id.

Step 8: Test Your API​

Start your server:

bun run index.ts

Now test all your endpoints:

List all todos​

curl http://localhost:4000/api/todos

Create a new todo​

curl -X POST http://localhost:4000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn TypeScript", "completed": false}'

Get a specific todo​

curl http://localhost:4000/api/todos/1

Update a todo​

curl -X PUT http://localhost:4000/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"completed": true}'

Delete a todo​

curl -X DELETE http://localhost:4000/api/todos/1

Step 9: Explore the Documentation​

Visit http://localhost:4000/docs to see your complete API documentation. You should see:

  • All five endpoints (GET, POST, PUT, DELETE)
  • Request/response schemas
  • Validation rules
  • Example requests and responses
  • Interactive testing interface
Validation in Action

Notice how the OpenAPI docs show the validation rules from your Zod schemas. BurgerAPI automatically converts Zod schemas to OpenAPI specifications!

Project Structure​

Your project should now look like this:

todo-api/
├── api/
│ └── todos/
│ ├── route.ts # GET /api/todos, POST /api/todos
│ └── [id]/
│ └── route.ts # GET, PUT, DELETE /api/todos/[id]
├── database.ts # In-memory storage
├── schemas.ts # Zod validation schemas
├── types.ts # TypeScript interfaces
├── index.ts # Server configuration
└── package.json

Build for Production (Optional)​

Just like with the Hello World tutorial, you can build this Todo API for production using the burger-api CLI:

# From the project root
burger-api build src/index.ts

This generates a bundle at:

  • .build/bundle/app.js

To create a standalone executable:

burger-api build:exec src/index.ts

Which produces:

  • .build/executable/<project> (or .exe on Windows)

These commands use the same AOT route discovery described in Migrating to 0.9 and the v0.9.3 release post.

Key Concepts Learned​

Dynamic Routes​

  • Use [paramName] folders to create dynamic route segments
  • Access parameters via req.params.paramName

Request Validation​

  • Use Zod schemas to validate incoming data
  • Return helpful error messages for validation failures
  • Use safeParse() for non-throwing validation

HTTP Status Codes​

  • 200 - Success
  • 201 - Created (for POST requests)
  • 204 - No Content (for DELETE requests)
  • 400 - Bad Request (validation errors)
  • 404 - Not Found

Error Handling​

  • Always validate input data
  • Return consistent error response format
  • Include helpful error messages

Next Steps​

Excellent work! You've built a complete CRUD API with validation. You now understand:

  • ✅ Multiple HTTP methods
  • ✅ Dynamic routes and parameters
  • ✅ Request validation with Zod
  • ✅ Error handling and status codes
  • ✅ Data storage patterns

Ready for something even more complex? In the next tutorial, you'll build a Blog API with multiple related resources, middleware, and advanced features!

Troubleshooting​

"Validation failed" errors

  • Check that your request body matches the Zod schema
  • Make sure you're sending Content-Type: application/json header

"Todo not found" errors

  • Verify the todo ID exists by checking GET /api/todos first
  • Make sure you're using a valid number for the ID

TypeScript errors

  • Ensure all imports are correct
  • Check that your types match between files

Route not found

  • Make sure your folder structure matches the expected pattern
  • Verify file names are exactly route.ts