The HONC stack - an acronym for Hono, ORM (Drizzle), Neon, and Cloudflare - is a modern toolkit for building lightweight, type-safe, and edge-enabled data APIs. It's designed for developers seeking to build fast, serverless applications with a strong emphasis on scalability and a great developer experience.
This guide will walk you through building a simple Task management API using the HONC stack. You'll learn how to:
- Initialize a HONC project using
create-honc-app
. - Define your database schema with Drizzle ORM.
- Use Neon as your serverless Postgres database.
- Create API endpoints using the Hono framework.
- Run your application locally and deploy it to Cloudflare Workers.
- Utilize the built-in Fiberplane API playground for easy testing.
By the end, you'll have a functional serverless API and a solid understanding of how the HONC components work together.
Prerequisites
Before you begin, ensure you have the following:
- Node.js: Version
22.15
or later installed on your machine. You can download it from nodejs.org. - Neon account: A free Neon account. If you don't have one, sign up at Neon.
- Cloudflare account: A free Cloudflare account, which you'll need for deployment. Sign up at Cloudflare.
Initialize your HONC project
The easiest way to start a HONC project is by using the
create-honc-app
CLI tool.-
Open your terminal and run the following command:
npm create honc-app@latest
Node.js version
Use Node.js version
22.15
or later. Older versions may cause project initialization issues. Check your version with:node -v
-
The CLI will guide you through the setup process. Here's an example interaction:
npm create honc-app@latest > npx > create-honc-app __ __ ______ __ __ ______ /\ \_\ \ /\ __ \ /\ "-.\ \ /\ ___\ \ \ __ \ \ \ \/\ \ \ \ \-. \ \ \ \____ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ ┌ 🪿 create-honc-app │ ◇ Where should we create your project? (./relative-path) │ ./honc-task-api │ ◇ Which template do you want to use? │ Neon template │ ◇ Do you need an OpenAPI spec? │ Yes │ ◇ The selected template uses Neon, do you want the create-honc-app to set up the connection string for you? │ Yes │ ◇ Do you want to install dependencies? │ Yes │ ◇ Do you want to initialize a git repository and stage all the files? │ Yes | ◆ Template set up successfully │ ◇ Setting up Neon: │ │ In order to connect to your database project and retrieve the connection key, you'll need to authenticate with Neon. │ │ The connection URI will be written to your .dev.vars file as DATABASE_URL. The token itself will *NOT* be stored anywhere after this session is complete. │ ◇ Awaiting authentication in web browser. Auth URL: │ │ https://oauth2.neon.tech/oauth2/auth?response_type=code&client_id=create-honc-app&state=[...]&scope=[...]&redirect_uri=[...]&code_challenge=[...]&code_challenge_method=S256 │ ◆ Neon authentication successful │ ◇ Select a Neon project to use: │ Create a new project │ ◇ What is the name of the project? │ honc-task-api │ ◆ Project created successfully: honc-task-api on branch: main │ ◇ Select a project branch to use: │ main │ ◇ Select a database you want to connect to: │ neondb │ ◇ Select which role to use to connect to the database: │ neondb_owner │ ◇ Writing connection string to .dev.vars file │ ◆ Neon connection string written to .dev.vars file │ ◆ Dependencies installed successfully │ ◆ Git repository initialized and files staged successfully │ └ 🪿 HONC app created successfully in ./honc-task-api!
Here's a breakdown of the options:
- Where to create your project: Specify the directory for your new project. Here, we used
./honc-task-api
. - Template: Choose the Neon template for this guide.
- OpenAPI spec: Opt-in to generate an OpenAPI spec for your API.
- Neon connection string: Allow the CLI to set up the connection string for you.
- Install dependencies: Yes, to install the required packages.
- Git repository: Yes, to initialize a git repository and stage all files.
- Neon authentication: Follow the link to authenticate with Neon. This will allow the CLI to set up your database connection.
- Create a new project: Choose to create a new Neon project or use an existing one. Here, we created a new one.
- Project name: Provide a name for your Neon project (e.g.,
honc-task-api
) if creating a new one. - Project branch: Select the main branch for your Neon project.
- Database: Choose the default database (e.g.,
neondb
). - Role: Select the
neondb_owner
role for database access. - Connection string: The CLI will write the connection string to a
.dev.vars
file in your project directory. - Setup: The CLI will set up the project, install dependencies, and initialize a git repository.
- Where to create your project: Specify the directory for your new project. Here, we used
-
Navigate into your new project directory.
cd honc-task-api
-
Open the project in your favorite code editor.
-
Confirm Neon connection
If you chose to let
create-honc-app
set up the connection string, your NeonDATABASE_URL
should already be in the.dev.vars
file in your project root. This file is used by Wrangler (Cloudflare's CLI) for local development and is gitignored by default.Verify its content:
// .dev.vars DATABASE_URL="postgresql://neondb_owner:..."
If you didn't use the CLI for setup, copy
.dev.vars.example
to.dev.vars
. Then, manually add your Neon project'sDATABASE_URL
to the.dev.vars
file. You can find your connection string in the Neon console. Learn more: Connect from any applicationDefine database schema with Drizzle
The
create-honc-app
template comes with an example schema (forusers
) insrc/db/schema.ts
. You need to modify this to define atasks
table.-
Open
src/db/schema.ts
. Remove the existingusers
schema definition. Add the following schema definition fortasks
:import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core'; export type NewUser = typeof users.$inferInsert; export const users = pgTable('users', { id: uuid('id').defaultRandom().primaryKey(), name: text('name').notNull(), email: text('email').notNull(), settings: jsonb('settings'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); export const tasks = pgTable('tasks', { id: serial('id').primaryKey(), title: text('title').notNull(), description: text('description'), completed: boolean('completed').default(false).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); export type Task = typeof tasks.$inferSelect; export type NewTask = typeof tasks.$inferInsert;
The tasks table schema defines the structure for storing tasks. It includes:
- A unique, auto-incrementing integer
id
. title
anddescription
fields.- A
completed
status. createdAt
andupdatedAt
timestamps to track creation and modification times.
For type safety when interacting with tasks (e.g., selecting or inserting),
Task
andNewTask
types are exported. These types are inferred from the schema and can be used throughout the application. - A unique, auto-incrementing integer
-
Generate and apply database migrations
With the schema updated, generate and apply database migrations.
-
Generate migrations:
npm run db:generate
This creates SQL migration files in the
drizzle
folder. -
Apply migrations:
npm run db:migrate
This applies the migrations to your Neon database. Your
tasks
table should now exist. You can verify this in the Tables section of your Neon project console.
-
Adapt API endpoints for tasks
The
src/index.ts
file generated bycreate-honc-app
will contain Hono routes and Zod schemas for a sampleusers
API. You need to adapt this foundation to create a RESTful API for managing ourtasks
. This involves defining how clients can interact with our tasks data through standard HTTP methods (GET
,POST
,PUT
,DELETE
).-
Open
src/index.ts
. You'll see code withUserSchema
, anapiRouter
instance for/api/users
, Zod validators, anddescribeRoute
for OpenAPI documentation. -
Modify Zod schemas: First, define the expected structure of task data for API requests and responses using Zod. This ensures type safety and provides a clear contract. Find the existing
UserSchema
and related definitions and replace them with schemas forTask
(how a task looks when retrieved) andNewTask
(how a new task looks when being created).// ... import statements and middleware for database connection const UserSchema = z .object({ id: z.number().openapi({ example: 1, }), name: z.string().openapi({ example: 'Nikita', }), email: z.string().email().openapi({ example: 'nikita@neon.tech', }), }) .openapi({ ref: 'User' }); const TaskSchema = z .object({ id: z.string().openapi({ description: 'The unique identifier for the task.', example: '1', }), title: z.string().openapi({ description: 'The title of the task.', example: 'Learn HONC', }), description: z.string().nullable().optional().openapi({ description: 'A detailed description of the task.', example: 'Build a complete task API with the HONC Stack', }), completed: z.boolean().openapi({ description: 'Indicates if the task is completed.', example: false, }), createdAt: z.string().datetime().openapi({ description: 'The date and time when the task was created.', example: new Date().toISOString(), }), updatedAt: z.string().datetime().openapi({ description: 'The date and time when the task was last updated.', example: new Date().toISOString(), }), }) .openapi({ ref: 'Task' }); const NewTaskSchema = z .object({ title: z.string().min(1, 'Title cannot be empty').openapi({ example: 'Deploy to Cloudflare', }), description: z.string().nullable().optional().openapi({ example: 'Finalize deployment steps for the task API.', }), }) .openapi({ ref: 'NewTask' });
Here's a breakdown of the Zod schemas:
TaskSchema
defines the full structure of a task for API responses.NewTaskSchema
defines the structure for creating a new task.- The
.openapi({ ref: "..." })
annotations are used to generate OpenAPI documentation.
-
Adapt API router: The
apiRouter
groups related routes. We'll modify the one for/api/users
to handle/api/tasks
.-
Locate where
app.route
is defined for/api/users
and change it to/api/tasks
:app .get( "/", describeRoute({...}) ) .route("/api/users", apiRouter); .route("/api/tasks", apiRouter);
-
Inside
apiRouter
, modify the CRUD operations. For each route:describeRoute
adds OpenAPI documentation.zValidator
validates request parameters or JSON bodies.- The
async
handler interacts with the database via Drizzle.
Here's the adapted
apiRouter
code for tasks with CRUD operations:// In src/index.ts, adapt the apiRouter for tasks const apiRouter = new Hono<{ Bindings: Bindings; Variables: Variables }>(); apiRouter .get( "/", describeRoute({...}) ) .post( "/", describeRoute({...}), zValidator( "json", // ... Zod schema for POST (users) ... ) ) .get( "/:id", describeRoute({...}), zValidator( "param", // ... Zod schema for GET by ID (users) ... ) ); apiRouter .get( "/", describeRoute({ summary: "List all tasks", description: "Retrieves a list of all tasks, ordered by creation date.", responses: { 200: { content: { "application/json": { schema: resolver(z.array(TaskSchema)) }, }, description: "Tasks fetched successfully", }, }, }), async (c) => { const db = c.get("db"); const tasks = await db .select() .from(schema.tasks) .orderBy(desc(schema.tasks.createdAt)); return c.json(tasks, 200); }, ) .post( "/", describeRoute({ summary: "Create a new task", description: "Adds a new task to the list.", responses: { 201: { content: { "application/json": { schema: resolver(TaskSchema), }, }, description: "Task created successfully", }, 400: { description: "Invalid input for task creation", }, }, }), zValidator("json", NewTaskSchema), async (c) => { const db = c.get("db"); const { title, description } = c.req.valid("json"); const newTaskPayload: schema.NewTask = { title, description: description || null, completed: false, }; const [insertedTask] = await db .insert(schema.tasks) .values(newTaskPayload) .returning(); return c.json(insertedTask, 201); }, ) .get( "/:id", describeRoute({ summary: "Get a single task by ID", responses: { 200: { content: { "application/json": { schema: resolver(TaskSchema) } }, description: "Task fetched successfully", }, 404: { description: "Task not found" }, 400: { description: "Invalid ID format" }, }, }), zValidator( "param", z.object({ id: z.string().openapi({ param: { name: "id", in: "path" }, example: "1", description: "The ID of the task to retrieve", }), }), ), async (c) => { const db = c.get("db"); const { id } = c.req.valid("param"); const [task] = await db .select() .from(schema.tasks) .where(eq(schema.tasks.id, Number(id))); if (!task) { return c.json({ error: "Task not found" }, 404); } return c.json(task, 200); }, ) .put( "/:id", describeRoute({ summary: "Update a task's completion status", description: "Toggles or sets the completion status of a specific task.", responses: { 200: { content: { "application/json": { schema: resolver(TaskSchema) } }, description: "Task updated successfully", }, 404: { description: "Task not found" }, 400: { description: "Invalid input or ID format" }, }, }), zValidator( "param", z.object({ id: z.string().openapi({ param: { name: "id", in: "path" }, example: "1", description: "The ID of the task to update.", }), }), ), zValidator( "json", z .object({ completed: z.boolean().openapi({ example: true, description: "The new completion status of the task.", }), }) ), async (c) => { const db = c.get("db"); const { id } = c.req.valid("param"); const { completed } = c.req.valid("json"); const [updatedTask] = await db .update(schema.tasks) .set({ updatedAt: sql`NOW()`, completed }) .where(eq(schema.tasks.id, Number(id))) .returning(); if (!updatedTask) { return c.json({ error: "Task not found" }, 404); } return c.json(updatedTask, 200); }, ) .delete( "/:id", describeRoute({ summary: "Delete a task", description: "Removes a specific task from the list.", responses: { 200: { content: { "application/json": { schema: resolver( z.object({ message: z.string(), id: z.string() }), ), }, }, description: "Task deleted successfully", }, 404: { description: "Task not found" }, 400: { description: "Invalid ID format" }, }, }), zValidator( "param", z.object({ id: z.string().openapi({ param: { name: "id", in: "path" }, example: "1", description: "The ID of the task to delete.", }), }), ), async (c) => { const db = c.get("db"); const { id } = c.req.valid("param"); const [deletedTask] = await db .delete(schema.tasks) .where(eq(schema.tasks.id, Number(id))) .returning({ id: schema.tasks.id }); if (!deletedTask) { return c.json({ error: "Task not found" }, 404); } return c.json( { message: "Task deleted successfully", id: deletedTask.id }, 200, ); }, );
Breakdown of the API endpoints:
GET /
(List tasks): Fetches all tasks from theschema.tasks
table usingdb.select()
. It orders them bycreatedAt
in descending order so newer tasks appear first. The response is a JSON array ofTaskSchema
objects.POST /
(Create task):- Validates the incoming JSON request body against
NewTaskSchema
(requirestitle
,description
is optional). - If valid, it constructs a
newTaskPayload
(settingcompleted
tofalse
by default). - Inserts the new task into
schema.tasks
usingdb.insert().values().returning()
to get the newly created task (including its auto-generated ID and timestamps). - Returns the created task (matching
TaskSchema
) with a201 Created
status.
- Validates the incoming JSON request body against
GET /:id
(Get task by ID):- Fetches a single task from
schema.tasks
where theid
matches. - Returns the task if found, or a
404 Not Found
error.
- Fetches a single task from
PUT /:id
(Update task):- Validates the
id
path parameter. - Validates the incoming JSON request body against
z.object({ completed: z.boolean() })
. - Updates the task's
completed
status andupdatedAt
timestamp inschema.tasks
.
- Validates the
DELETE /:id
(Delete task):- Validates the
id
path parameter. - Deletes the task with the matching
id
fromschema.tasks
. - Returns a success message with the ID of the deleted task, or a
404 Not Found
.
- Validates the
-
-
Run and test locally
Run your HONC application locally using Wrangler:
-
In your terminal, at the root of your project:
npm run dev
This starts a local server, typically at
http://localhost:8787
. -
Test your API endpoints: You can use tools like cURL, Postman, or the Fiberplane API Playground (see next section).
-
Create a task:
curl -X POST -H "Content-Type: application/json" -d '{"title":"Learn HONC","description":"Build a task API"}' http://localhost:8787/api/tasks
A successful response should return the created task with a unique ID.
{ "id": 1, "title": "Learn HONC", "description": "Build a task API", "completed": false, "createdAt": "2025-05-14T09:17:25.392Z", "updatedAt": "2025-05-14T09:17:25.392Z" }
You can also verify if the task was added to your database by checking your project in the Neon console. The task should appear in the
tasks
table. -
List all tasks:
curl http://localhost:8787/api/tasks
A successful response should return an array of tasks.
[ { "id": 1, "title": "Learn HONC", "description": "Build a task API", "completed": false, "createdAt": "2025-05-14T09:17:25.392Z", "updatedAt": "2025-05-14T09:17:25.392Z" } ]
-
Get a specific task (replace
TASK_ID
with an actual ID from the list):curl http://localhost:8787/api/tasks/TASK_ID
For example, if the ID is
1
:curl http://localhost:8787/api/tasks/1
A successful response should return the task with ID
1
.{ "id": 1, "title": "Learn HONC", "description": "Build a task API", "completed": false, "createdAt": "2025-05-14T09:17:25.392Z", "updatedAt": "2025-05-14T09:17:25.392Z" }
-
Update a task (replace
TASK_ID
):curl -X PUT -H "Content-Type: application/json" -d '{"completed":true}' http://localhost:8787/api/tasks/TASK_ID
For example, if the ID is
1
:curl -X PUT -H "Content-Type: application/json" -d '{"completed":true}' http://localhost:8787/api/tasks/1
A successful response should return the updated task.
{ "id": 1, "title": "Learn HONC Stack", "description": "Build a task API", "completed": true, "createdAt": "2025-05-14T09:17:25.392Z", "updatedAt": "2025-05-14T09:17:25.392Z" }
-
Delete a task (replace
TASK_ID
):curl -X DELETE http://localhost:8787/api/tasks/TASK_ID
For example, if the ID is
1
:curl -X DELETE http://localhost:8787/api/tasks/1
A successful response should return a message confirming deletion.
{ "message": "Task deleted successfully", "id": 1 }
-
Interactive Testing with Fiberplane API Playground
The
create-honc-app
boilerplate includes integration with the Fiberplane API Playground, an in-browser tool designed for interacting with your HONC API during development.To access it, simply ensure your local development server is running via
npm run dev
. Once the server is active, open your web browser and navigate tolocalhost:8787/fp
.Within the playground, you'll find a visual exploration of your API. It reads your
/openapi.json
spec (generated byhono-openapi
if enabled) to display all your defined API endpoints, such as/api/tasks
or/api/tasks/{id}
, within a user-friendly interface. This allows for easy request crafting; you can select an endpoint and fill in necessary parameters, path variables, and request bodies directly within the UI.This is incredibly useful for quick testing and debugging cycles during development, reducing the frequent need for external tools like Postman or cURL.
-
Deploy to Cloudflare Workers
Deploy your application globally via Cloudflare's edge network.
-
Set
DATABASE_URL
secret in Cloudflare: Your deployed Worker needs the Neon database connection string.npx wrangler secret put DATABASE_URL
Paste your Neon connection string when prompted.
npx wrangler secret put DATABASE_URL ⛅️ wrangler 4.14.4 ------------------- ✔ Enter a secret value: … ************************************************************************************************************************ 🌀 Creating the secret for the Worker "honc-task-api" ✔ There doesn't seem to be a Worker called "honc-task-api". Do you want to create a new Worker with that name and add secrets to it? … yes 🌀 Creating new Worker "honc-task-api"... ✨ Success! Uploaded secret DATABASE_URL
Steps may vary based on your Cloudflare account and login status. Ensure you are logged in if prompted.
-
Deploy:
npm run deploy
Wrangler will deploy your application to Cloudflare Workers. The output will show the deployment status and the URL of your deployed Worker.
npm run deploy > deploy > wrangler deploy --minify src/index.ts ⛅️ wrangler 4.14.4 ------------------- Total Upload: 505.17 KiB / gzip: 147.10 KiB Worker Startup Time: 32 ms No bindings found. Uploaded honc-task-api (13.49 sec) Deployed honc-task-api triggers (3.50 sec) https://honc-task-api.[xxx].workers.dev Current Version ID: b0c90b17-f10a-4807-xxxx
-
Summary
Congratulations! You've successfully adapted the create-honc-app
boilerplate to build a serverless Task API using the HONC stack. You've defined a schema with Drizzle, created Hono endpoints with Zod validation, tested locally using tools like cURL and the integrated Fiberplane API Playground, and learned how to deploy to Cloudflare Workers.
The HONC stack offers a streamlined, type-safe, and performant approach to building modern edge APIs.
You can find the source code for the application described in this guide on GitHub.
Resources
- HONC: honc.dev, create-honc-app GitHub
- Fiberplane API Playground: Hono-native API Playground, powered by OpenAPI, Features
- Hono: hono.dev
- Drizzle ORM: orm.drizzle.team
- Neon: neon.tech/docs
- Cloudflare Workers: developers.cloudflare.com/workers
Need help?
Join our Discord Server to ask questions or see what others are doing with Neon. Users on paid plans can open a support ticket from the console. For more details, see Getting Support.