DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Build a Type-Safe REST API with Hono.js in 2026

Hono.js has become one of the most exciting web frameworks in the JavaScript ecosystem. As of February 2026, it's the go-to choice for developers who want ultrafast APIs that run on any JavaScript runtime — Cloudflare Workers, Bun, Deno, Node.js, AWS Lambda, and more.

In this tutorial, you'll build a complete type-safe REST API with Hono.js from scratch, including input validation with Zod, proper error handling, and middleware.

Why Hono.js?

Hono (meaning "flame" 🔥 in Japanese) stands out for several reasons:

  • Ultrafast — Its RegExpRouter uses a trie-based approach, not linear loops
  • Lightweight — The hono/tiny preset is under 14kB with zero dependencies
  • Multi-runtime — Write once, deploy anywhere (Cloudflare, Bun, Deno, Node.js, AWS Lambda)
  • Type-safe — First-class TypeScript support with inferred types across your entire request/response chain
  • Built on Web Standards — Uses the Request and Response APIs you already know

Prerequisites

  • Node.js 22+ (or Bun 1.2+)
  • Basic TypeScript knowledge
  • A code editor with TypeScript support

Step 1: Scaffold Your Project

The fastest way to start a Hono project:

npm create hono@latest my-api
cd my-api
npm install
Enter fullscreen mode Exit fullscreen mode

When prompted, select your target runtime. For this tutorial, we'll use Node.js, but the code works on any runtime.

Next, install Zod for request validation:

npm install zod @hono/zod-validator
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Your Data Model

Let's build a simple task management API. Create src/models.ts:

import { z } from 'zod';

export const TaskSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
  description: z.string().max(1000).optional(),
  status: z.enum(['todo', 'in_progress', 'done']),
  createdAt: z.string().datetime(),
});

export const CreateTaskSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(1000).optional(),
});

export const UpdateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(1000).optional(),
  status: z.enum(['todo', 'in_progress', 'done']).optional(),
});

export type Task = z.infer<typeof TaskSchema>;
export type CreateTask = z.infer<typeof CreateTaskSchema>;
export type UpdateTask = z.infer<typeof UpdateTaskSchema>;
Enter fullscreen mode Exit fullscreen mode

Notice how Zod gives us both runtime validation and TypeScript types from a single source of truth. No duplication.

Step 3: Build the API Routes

Replace the contents of src/index.ts:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { prettyJSON } from 'hono/pretty-json';
import { zValidator } from '@hono/zod-validator';
import { randomUUID } from 'crypto';
import { CreateTaskSchema, UpdateTaskSchema, type Task } from './models';

const app = new Hono();

// Middleware
app.use('*', logger());
app.use('*', cors());
app.use('*', prettyJSON());

// In-memory store (swap for a database in production)
const tasks = new Map<string, Task>();

// GET /tasks — List all tasks
app.get('/tasks', (c) => {
  const allTasks = Array.from(tasks.values());
  return c.json({ tasks: allTasks, count: allTasks.length });
});

// GET /tasks/:id — Get a single task
app.get('/tasks/:id', (c) => {
  const task = tasks.get(c.req.param('id'));
  if (!task) {
    return c.json({ error: 'Task not found' }, 404);
  }
  return c.json(task);
});

// POST /tasks — Create a new task
app.post(
  '/tasks',
  zValidator('json', CreateTaskSchema),
  (c) => {
    const body = c.req.valid('json');
    const task: Task = {
      id: randomUUID(),
      title: body.title,
      description: body.description,
      status: 'todo',
      createdAt: new Date().toISOString(),
    };
    tasks.set(task.id, task);
    return c.json(task, 201);
  }
);

// PATCH /tasks/:id — Update a task
app.patch(
  '/tasks/:id',
  zValidator('json', UpdateTaskSchema),
  (c) => {
    const id = c.req.param('id');
    const existing = tasks.get(id);
    if (!existing) {
      return c.json({ error: 'Task not found' }, 404);
    }
    const updates = c.req.valid('json');
    const updated: Task = { ...existing, ...updates };
    tasks.set(id, updated);
    return c.json(updated);
  }
);

// DELETE /tasks/:id — Delete a task
app.delete('/tasks/:id', (c) => {
  const id = c.req.param('id');
  if (!tasks.delete(id)) {
    return c.json({ error: 'Task not found' }, 404);
  }
  return c.json({ message: 'Task deleted' });
});

export default app;
Enter fullscreen mode Exit fullscreen mode

The zValidator middleware automatically validates the request body against your Zod schema before your handler runs. If validation fails, it returns a 400 error with detailed messages. The validated data is fully typed in your handler — no casts needed.

Step 4: Add Custom Error Handling

Hono provides a clean way to handle errors globally. Add this before your routes:

import { HTTPException } from 'hono/http-exception';

// Global error handler
app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return c.json(
      { error: err.message, status: err.status },
      err.status
    );
  }
  console.error('Unhandled error:', err);
  return c.json(
    { error: 'Internal server error', status: 500 },
    500
  );
});

// 404 handler
app.notFound((c) => {
  return c.json(
    { error: 'Not found', path: c.req.path },
    404
  );
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Add Authentication Middleware

Let's add a simple API key middleware to protect our routes:

import { createMiddleware } from 'hono/factory';

const authMiddleware = createMiddleware(async (c, next) => {
  const apiKey = c.req.header('X-API-Key');
  if (!apiKey || apiKey !== process.env.API_KEY) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  await next();
});

// Apply to all task routes
app.use('/tasks/*', authMiddleware);
Enter fullscreen mode Exit fullscreen mode

Using createMiddleware from hono/factory gives you full type inference within your middleware — the context object knows about upstream middleware and variables.

Step 6: Run and Test

Start the server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Test your endpoints:

# Create a task
curl -X POST http://localhost:3000/tasks \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: your-secret-key' \
  -d '{"title": "Learn Hono.js", "description": "Build a REST API"}'

# List all tasks
curl http://localhost:3000/tasks \
  -H 'X-API-Key: your-secret-key'

# Try invalid input (validation will reject it)
curl -X POST http://localhost:3000/tasks \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: your-secret-key' \
  -d '{"title": ""}'
Enter fullscreen mode Exit fullscreen mode

The last request returns a 400 with details about why validation failed — title must be at least 1 character.

Step 7: Deploy Anywhere

Hono's multi-runtime support means you can deploy the same code to different platforms with minimal changes.

Deploy to Cloudflare Workers:

npx wrangler deploy
Enter fullscreen mode Exit fullscreen mode

Deploy with Bun:

// src/index.ts — just change the export
export default {
  port: 3000,
  fetch: app.fetch,
};
Enter fullscreen mode Exit fullscreen mode

Deploy to AWS Lambda:

import { handle } from 'hono/aws-lambda';
export const handler = handle(app);
Enter fullscreen mode Exit fullscreen mode

Same business logic, different deployment target. That's the power of building on Web Standards.

Performance Tip: Use RPC Mode

Hono has a built-in RPC feature that lets your frontend call your API with full type safety — no code generation needed:

// Server: export the type
const route = app.post(
  '/tasks',
  zValidator('json', CreateTaskSchema),
  (c) => {
    // ... handler
    return c.json(task, 201);
  }
);
export type AppType = typeof route;

// Client: use hc (Hono Client)
import { hc } from 'hono/client';
import type { AppType } from './server';

const client = hc<AppType>('http://localhost:3000');
const res = await client.tasks.$post({
  json: { title: 'Type-safe!' },
});
// res is fully typed — no codegen, no OpenAPI spec needed
Enter fullscreen mode Exit fullscreen mode

This gives you end-to-end type safety from your API schema all the way to your frontend code.

Wrapping Up

Hono.js gives you the speed of lightweight frameworks with the developer experience of full-featured ones. Its combination of Web Standards, multi-runtime support, and first-class TypeScript makes it an excellent choice for building APIs in 2026.

Key takeaways:

  • Use Zod + @hono/zod-validator for type-safe validation
  • Leverage built-in middleware (cors, logger, prettyJSON) instead of installing separate packages
  • Use hono/factory for typed custom middleware
  • Deploy the same code anywhere with minimal adapter changes
  • Try RPC mode for end-to-end type safety with your frontend

If you're building APIs that need to be fast, portable, and type-safe, give Hono.js a try. You might not go back.


Building APIs and need reliable infrastructure? Check out 1xAPI for production-ready API services including email verification, IP geolocation, and more.

Top comments (0)