How to build a todo API in Node.js
Building a todo API is the ideal first Node.js project because it covers every fundamental backend concept: creating, reading, updating, and deleting resources with a clean REST interface. As the creator of CoreUI with 25 years of backend development experience, I use this exact project structure as the starting point for more complex Node.js APIs. The key is using Prisma as the ORM for type-safe database access and adding input validation with Zod before any data touches the database. This combination gives you reliable data integrity and TypeScript-compatible queries from day one.
Set up the project with Express, Prisma, and Zod.
npm init -y
npm install express @prisma/client zod
npm install --save-dev prisma typescript @types/express
npx prisma init --datasource-provider sqlite
// prisma/schema.prisma
model Todo {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
npx prisma migrate dev --name init
npx prisma generate
SQLite is used here for simplicity — replace with postgresql for production. @updatedAt automatically sets the timestamp whenever a record changes.
The Todo Router
Define all CRUD routes in a dedicated router.
// src/todos/todos.router.js
import { Router } from 'express'
import { PrismaClient } from '@prisma/client'
import { z } from 'zod'
const router = Router()
const prisma = new PrismaClient()
const createSchema = z.object({
title: z.string().min(1).max(200)
})
const updateSchema = z.object({
title: z.string().min(1).max(200).optional(),
completed: z.boolean().optional()
})
// GET /todos
router.get('/', async (req, res, next) => {
try {
const todos = await prisma.todo.findMany({ orderBy: { createdAt: 'desc' } })
res.json(todos)
} catch (err) {
next(err)
}
})
// POST /todos
router.post('/', async (req, res, next) => {
try {
const { title } = createSchema.parse(req.body)
const todo = await prisma.todo.create({ data: { title } })
res.status(201).json(todo)
} catch (err) {
next(err)
}
})
// PATCH /todos/:id
router.patch('/:id', async (req, res, next) => {
try {
const id = Number(req.params.id)
const data = updateSchema.parse(req.body)
const todo = await prisma.todo.update({ where: { id }, data })
res.json(todo)
} catch (err) {
next(err)
}
})
// DELETE /todos/:id
router.delete('/:id', async (req, res, next) => {
try {
const id = Number(req.params.id)
await prisma.todo.delete({ where: { id } })
res.status(204).send()
} catch (err) {
next(err)
}
})
export { router as todosRouter }
z.object().parse() throws a ZodError if validation fails. Pass the error to next(err) and your error handler will return a 400 response with the validation message. This keeps route handlers free of complex validation logic.
The Express App
Wire everything together.
// src/app.js
import express from 'express'
import { todosRouter } from './todos/todos.router.js'
import { errorHandler } from './middleware/error.handler.js'
const app = express()
app.use(express.json())
app.use('/todos', todosRouter)
app.use(errorHandler)
export { app }
// src/middleware/error.handler.js
import { ZodError } from 'zod'
export function errorHandler(err, req, res, next) {
if (err instanceof ZodError) {
return res.status(400).json({ error: err.errors })
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Todo not found' })
}
res.status(500).json({ error: 'Internal server error' })
}
P2025 is Prisma’s error code for “record not found”. Catching it in the error handler means you don’t need to add not-found checks in every route.
Best Practice Note
This is the same service structure we recommend in CoreUI Node.js project templates. For a production API, add pagination to the GET /todos route using skip and take query parameters, and add authentication middleware before the router. Once you’ve built the todo API, scale up to a notes API in Node.js which adds user ownership and filtering.



