In the last blog we completed the setup for our monorepo todo list. In this one we will develop the API using hono. If you can't find the folder in your computer you can clone it from this repo by running this command from the terminal.
git clone https://github.com/parthivsaikia/todo-turbo
Now cd into this folder using cd todo-turbo.
Now to install and build all the dependencies run
pnpm i && pnpm build
Developing API using hono
We will first develop the APIs. Create a new folder named /routes inside the /src folder in /backend directory. In this /routes folder we will create our API endpoints.
The app will have two main entities - users and todos. Each user can create multiple todos. The API related to each entity will reside in their respective files in the /routes folder.
Creating API endpoints for users.
Create a user.ts file in the /routes folder. This file will contain the CRUD APIs related to the user entity.
import prisma from "@repo/db";
import { Hono } from "hono";
Import the shared Prisma client and hono instance at the top of user.ts. Notice that we import the prisma client from the internal package @repo/db package. Hono represents the app instance that we will create. A Hono instance is already present in the src/index.ts of the same folder. This is the main Hono app instance and all the other instances such as user, todo, will be added to it.
Create a new Hono instance.
const userRouter = new Hono();
While Hono allows any name we will use the suffix "Router" to distinguish these sub-applications from our main application defined at src/index.ts.
Let's define the route to handle user creation. The route will take the name of the user and will store it in the database.
userRouter.post("/", async (c, next) => {
try {
const { name } = await c.req.json();
const user = await prisma.user.create({
data: {
name,
},
});
return c.json({
id: user.id.toString(),
name: user.name,
}, 201);
} catch (error) {
const errorMsg =
error instanceof Error
? `error in creating user: ${error.message}`
: `unknown error in creating user.`;
throw new Error(errorMsg);
}
});
The endpoint accepts a name from the request body and uses it to create a new user through the Prisma client. The created user object is stored in a variable and returned as a JSON response using c.json(). Note that we're converting the user's ID to a string before sending the response—this is necessary because JSON cannot serialize BigInt values, which is the data type Prisma uses for ID fields. We return 201 Created as the status code cause a new user is created.
The try-catch block handles error management in this endpoint. The try block contains the code that might fail i.e parsing the request body and creating the user in the database. If everything executes successfully, the user data is returned. However, if an error occurs at any point (such as invalid input, database connectivity issues, or constraint violations), the catch block intercepts it. We then check whether the error is an Error instance to extract a meaningful error message, format it appropriately, and throw a new Error that can be handled by downstream error middleware.
Now let's create an endpoint which returns a list of all the users created.
userRouter.get("/", async (c, next) => {
try {
const users = await prisma.user.findMany({});
const usersWithIdAsString = users.map((user) => ({
id: user.id.toString(),
name: user.name,
}));
return c.json(usersWithIdAsString, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `error in fetching user: ${error.message}`
: `unknown error in fetching user.`;
throw new Error(errorMsg);
}
});
We use the findMany method which returns all the user objects stored in the database. In this route we are returning 200 OK status code since the route successfully returns the requested data.
Your final user.ts should look like this
import prisma from "@repo/db";
import { Hono } from "hono";
const userRouter = new Hono();
userRouter.post("/", async (c, next) => {
try {
const { name } = await c.req.json();
const user = await prisma.user.create({
data: {
name,
},
});
return c.json({
id: user.id.toString(),
name: user.name,
}, 201);
} catch (error) {
const errorMsg =
error instanceof Error
? `error in creating user: ${error.message}`
: `unknown error in creating user.`;
throw new Error(errorMsg);
}
});
userRouter.get("/", async (c, next) => {
try {
const users = await prisma.user.findMany({});
const usersWithIdAsString = users.map((user) => ({
id: user.id.toString(),
name: user.name,
}));
return c.json(usersWithIdAsString, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `error in fetching user: ${error.message}`
: `unknown error in fetching user.`;
throw new Error(errorMsg);
}
});
export default userRouter;
In the last line we are exporting the userRouter so that it can be added to the main Hono app.
Adding userRouter to main Hono app
The src/index.ts inside the /backend folder should look like this.
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
It defines a Hono app and at the root path shows the text "Hello Hono" at the port 3000. To verify this run pnpm run dev in the root directory of our monorepo and then visit http://localhost:3000. You should see the output text "Hello Hono" there.
Now to add the userRouter to this Hono app we need to do some modification to the index.ts .
Import the userRouter to src/index.ts.
import userRouter from "./routes/user.js"
Then add it to the Hono app using this line.
app.route("/users", userRouter)
The full index.ts should look like
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import userRouter from "./routes/user.js";
const app = new Hono();
app.get("/", (c) => {
return c.text("Hello Hono!");
});
serve(
{
fetch: app.fetch,
port: 3000,
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
},
);
Now the requests to create and get users can be made to http://localhost:3000/users.
Creating API endpoint for todos
Now let's create endpoints for todos. Since todos need to be created as well as deleted we are going to create all of the four "CRUD" endpoints.
First create a new file todo.ts in the /routes folder and import prisma client and Hono similar to user.ts . Then create a Hono app with the name todoRouter.
import prisma from "@repo/db";
import { Hono } from "hono";
const todoRouter = new Hono();
Now create a route to handle creation of todo. This is similar to the endpoint POST /users. We are taking the task, the ISO string of the dueDate and the userId as the inputs and using them to create a new todo with the help of prisma. We are changing the types of dueDate and userId because we have them as strings but prisma want them as date and BigInt respectively.
todoRouter.post("/", async (c, next) => {
try {
const { task, dueDate, userId } = await c.req.json();
const validatedUserId = BigInt(userId);
const validatedDueDate = new Date(dueDate);
const todo = await prisma.todo.create({
data: {
task,
dueDate: validatedDueDate,
userId: validatedUserId,
},
});
return c.json({
task,
dueDate,
id: todo.id.toString(),
userId,
}, 201);
} catch (error) {
const errorMsg =
error instanceof Error
? `error in creating todo: ${error.message}`
: `unknown error in creating todo.`;
throw new Error(errorMsg);
}
});
We also need an endpoint to list all the todos. In case of users we can fetch all of them and send them but for todos we shouldn't show all the todos to all the users. A user should only see the todos which are created by them. To solve this problem we will use params. To give you a better picture let me give you an example. Consider there is a user John with userId 1 and then another user James with userId 2. If we make a GET request to http://localhost:3000/todos/1 we should get the todos created by John and if we make the request to http://localhost:3000/todos/2 we should get the todos created by James. Here the userId is called the param. Let's see it in action.
todoRouter.get("/:userId", async (c) => {
try {
const userId = BigInt(c.req.param("userId"));
const todos = await prisma.todo.findMany({
where: {
userId,
},
});
// Map BigInts to strings for JSON serialization
const todosSuitableForJson = todos.map((todo) => ({
...todo,
id: todo.id.toString(),
userId: todo.userId.toString(),
}));
return c.json(todosSuitableForJson, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `Error in fetching todos: ${error.message}`
: `Unknown error in fetching todos.`;
throw new Error(errorMsg);
}
});
Here we are defining an endpoint :/userId which takes a request with a GET method and return the todos created by the user with id as userId.
The userId is provided as the param of the url and is extracted using c.req.param method. We have received this id as a string from the url but to use it with prisma we need it as a bigint, so we store it in the variable userId by converting it into bigInt.
By using prisma we get all the todos which has userId as userId.
These todos have two fields with the type bigint - id and userId which can't be serialized with json, so we create a new array todosSuitableForJson by mapping each todo and converting the id and userId to string. Then we export this new array todosSuitableForJson.
After these two endpoints we need to define two more endpoint to update and delete todos. Let's get started with the "update" endpoint.
The update endpoint will take id as the param and the changed data as input. It will find the todo with the given id and will update the todo with the new data. There are two HTTP methods for updating data : PUT and PATCH. The main difference between these two methods are that PUT replaces the old data with the new one completely but PATCH only changes the values where there is a change while keeping the unchanged ones untouched.
todoRouter.put("/:id", async (c) => {
try {
const { task, dueDate } = await c.req.json();
const validatedDueDate = new Date(dueDate);
const id = BigInt(c.req.param("id"));
const todo = await prisma.todo.update({
where: { id },
data: { task, dueDate: validatedDueDate },
});
return c.json({
task,
id: id.toString(),
userId: todo.userId,
dueDate,
}, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `error in updating todo: ${error.message}`
: `unknown error in updating todo.`;
throw new Error(errorMsg);
}
});
The logic used in this route mirrors the other routes discussed so far, the key difference being we've used the PUT method to ensure idempotent updates.
The delete endpoint will be created using the HTTP DELETE method. We need to identify the todo which is to be deleted using its id. So we will using the param id to find the todo.
todoRouter.delete("/:id", async (c) => {
try {
const id = BigInt(c.req.param("id"));
await prisma.todo.delete({
where: {
id,
},
});
return c.text(`successfully deleted todo with id ${id.toString()}`);
} catch (error) {
const errorMsg =
error instanceof Error
? `error at deleting todo: ${error.message}`
: `unknown error at deleting todo.`;
throw new Error(errorMsg);
}
});
To delete a todo we extract the id from the param and call the prisma.todo.delete() method with it. It deletes the todo from the database and returns the text response back to the client.
With this we can say that the APIs are enough for our simple to-do list. The full todo.ts is
import prisma from "@repo/db";
import { Hono } from "hono";
const todoRouter = new Hono();
// 1. Create a new Todo
todoRouter.post("/", async (c) => {
try {
const { task, dueDate, userId } = await c.req.json();
// Conversion for Prisma types
const validatedUserId = BigInt(userId);
const validatedDueDate = new Date(dueDate);
const todo = await prisma.todo.create({
data: {
task,
dueDate: validatedDueDate,
userId: validatedUserId,
},
});
return c.json({
task,
dueDate,
id: todo.id.toString(),
userId,
}, 201);
} catch (error) {
const errorMsg =
error instanceof Error
? `Error in creating todo: ${error.message}`
: `Unknown error in creating todo.`;
throw new Error(errorMsg);
}
});
// 2. GET Todos by User ID
todoRouter.get("/:userId", async (c) => {
try {
const userId = BigInt(c.req.param("userId"));
const todos = await prisma.todo.findMany({
where: {
userId,
},
});
// Map BigInts to strings for JSON serialization
const todosSuitableForJson = todos.map((todo) => ({
...todo,
id: todo.id.toString(),
userId: todo.userId.toString(),
}));
return c.json(todosSuitableForJson, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `Error in fetching todos: ${error.message}`
: `Unknown error in fetching todos.`;
throw new Error(errorMsg);
}
});
// 3. Update a Todo
todoRouter.put("/:id", async (c) => {
try {
const { task, dueDate } = await c.req.json();
const validatedDueDate = new Date(dueDate);
const id = BigInt(c.req.param("id"));
const todo = await prisma.todo.update({
where: { id },
data: { task, dueDate: validatedDueDate },
});
return c.json({
task,
id: id.toString(),
userId: todo.userId.toString(),
dueDate,
}, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `Error in updating todo: ${error.message}`
: `Unknown error in updating todo.`;
throw new Error(errorMsg);
}
});
// 4. Delete a Todo
todoRouter.delete("/:id", async (c) => {
try {
const id = BigInt(c.req.param("id"));
await prisma.todo.delete({
where: {
id,
},
});
return c.text(`Successfully deleted todo with id ${id.toString()}`, 200);
} catch (error) {
const errorMsg =
error instanceof Error
? `Error in deleting todo: ${error.message}`
: `Unknown error in deleting todo.`;
throw new Error(errorMsg);
}
});
export default todoRouter;
Now add todoRouter to the main Hono instance at src/index.ts. Import todoRouter into the file by adding this line at the top.
import todoRouter from "./routes/todo.js"
Then add the router instance to the main instance by adding
app.route("/todos", todoRouter);
What's Next?
With the User and Todo APIs complete, our backend logic is fully operational. We’ve successfully handled creating, reading, updating, and deleting resources while managing tricky edge cases like BigInt serialization.
In the next part, we will close the loop by connecting this API to our React frontend. You can find the complete source code for this section on github.




Top comments (0)