Building a REST API from Scratch with Deno and Hono

Team 9 min read

#deno

#hono

#rest

#api

#tutorial

If you want a modern, lightweight way to build APIs with minimal boilerplate, Deno plus Hono is a great stack. Deno gives you a secure, batteries-included runtime with TypeScript support out of the box. Hono is a tiny, fast web framework with an intuitive API and first-class Deno support.

In this tutorial you will:

  • Set up a Deno project with Hono
  • Build a Todo REST API with CRUD endpoints
  • Add middleware for logging and CORS
  • Handle errors and validation
  • Write a couple of lightweight tests
  • Optionally add persistence with Deno KV
  • Learn how to run and deploy

Prerequisites:

  • Deno installed (deno —version). Any recent 1.4x+ release is fine.

Project goals:

  • RESTful routes at /api/v1/todos
  • JSON responses with proper status codes
  • Clean structure and composable routing
  • Minimal dependencies

1) Project setup

  • Create folders and files:

    • src/main.ts
    • src/routes/todos.ts (optional, we will inline first then show how to split)
    • deno.json (for tasks, optional)
  • Install dependencies Deno uses URL imports. We will import Hono directly from deno.land.

2) A minimal server with Hono

Create src/main.ts with a working server and basic health route:

// src/main.ts
import { Hono } from 'https://deno.land/x/hono@v4.4.7/mod.ts'
import { logger } from 'https://deno.land/x/hono@v4.4.7/middleware/logger/index.ts'
import { cors } from 'https://deno.land/x/hono@v4.4.7/middleware/cors/index.ts'

type Todo = {
  id: string
  title: string
  completed: boolean
  createdAt: string
  updatedAt: string
}

// Simple in-memory store for now
const db = new Map<string, Todo>()

// You can add typed context variables if you want
const app = new Hono<{ Variables: { requestId: string } }>()

// Global middleware
app.use('*', logger())
app.use('*', cors())

// Attach a request ID to every request
app.use('*', async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})

// Health check
app.get('/health', (c) =>
  c.json({ status: 'ok', ts: new Date().toISOString() })
)

// Todos router
const todos = new Hono()

// List todos
todos.get('/', (c) => {
  const items = Array.from(db.values())
  return c.json(items)
})

// Read todo
todos.get('/:id', (c) => {
  const id = c.req.param('id')
  const t = db.get(id)
  if (!t) return c.json({ message: 'Not found' }, 404)
  return c.json(t)
})

// Create todo
todos.post('/', async (c) => {
  const body = await c.req.json().catch(() => null)
  if (!body || typeof body.title !== 'string' || body.title.trim().length === 0) {
    return c.json({ message: 'title is required' }, 400)
  }
  const id = crypto.randomUUID()
  const now = new Date().toISOString()
  const todo: Todo = {
    id,
    title: body.title.trim(),
    completed: false,
    createdAt: now,
    updatedAt: now,
  }
  db.set(id, todo)
  return c.json(todo, 201)
})

// Replace todo
todos.put('/:id', async (c) => {
  const id = c.req.param('id')
  const existing = db.get(id)
  if (!existing) return c.json({ message: 'Not found' }, 404)

  const body = await c.req.json().catch(() => null)
  if (!body || typeof body.title !== 'string' || typeof body.completed !== 'boolean') {
    return c.json({ message: 'title and completed are required' }, 400)
  }
  const now = new Date().toISOString()
  const todo: Todo = {
    ...existing,
    title: body.title.trim(),
    completed: body.completed,
    updatedAt: now,
  }
  db.set(id, todo)
  return c.json(todo)
})

// Partial update
todos.patch('/:id', async (c) => {
  const id = c.req.param('id')
  const existing = db.get(id)
  if (!existing) return c.json({ message: 'Not found' }, 404)

  const body = await c.req.json().catch(() => null)
  if (!body) return c.json({ message: 'Invalid JSON' }, 400)

  const now = new Date().toISOString()
  const todo: Todo = {
    ...existing,
    title: typeof body.title === 'string' ? body.title.trim() : existing.title,
    completed: typeof body.completed === 'boolean' ? body.completed : existing.completed,
    updatedAt: now,
  }
  db.set(id, todo)
  return c.json(todo)
})

// Delete todo
todos.delete('/:id', (c) => {
  const id = c.req.param('id')
  const ok = db.delete(id)
  return ok ? c.body(null, 204) : c.json({ message: 'Not found' }, 404)
})

// Mount router
app.route('/api/v1/todos', todos)

// 404 and error handling
app.notFound((c) => c.json({ message: 'Route not found' }, 404))
app.onError((err, c) => {
  console.error(err)
  return c.json({ message: 'Internal Server Error' }, 500)
})

const port = Number(Deno.env.get('PORT') ?? 8000)
console.log(`Server running on http://localhost:${port}`)
Deno.serve({ port }, app.fetch)

Run the server:

deno run --allow-net --allow-env src/main.ts
# or with all permissions during development:
deno run -A src/main.ts

Try it:

curl http://localhost:8000/health
curl http://localhost:8000/api/v1/todos
curl -X POST http://localhost:8000/api/v1/todos \
  -H 'content-type: application/json' \
  -d '{"title":"Learn Hono"}'

3) Project structure

As your app grows, split routers into files:

src/
  main.ts
  routes/
    todos.ts

Example src/routes/todos.ts:

// src/routes/todos.ts
import { Hono } from 'https://deno.land/x/hono@v4.4.7/mod.ts'

export type Todo = {
  id: string
  title: string
  completed: boolean
  createdAt: string
  updatedAt: string
}

export function createTodosRouter(db: Map<string, Todo>) {
  const r = new Hono()

  r.get('/', (c) => c.json(Array.from(db.values())))

  r.get('/:id', (c) => {
    const t = db.get(c.req.param('id'))
    return t ? c.json(t) : c.json({ message: 'Not found' }, 404)
  })

  r.post('/', async (c) => {
    const body = await c.req.json().catch(() => null)
    if (!body || typeof body.title !== 'string' || body.title.trim() === '') {
      return c.json({ message: 'title is required' }, 400)
    }
    const id = crypto.randomUUID()
    const now = new Date().toISOString()
    const todo: Todo = { id, title: body.title.trim(), completed: false, createdAt: now, updatedAt: now }
    db.set(id, todo)
    return c.json(todo, 201)
  })

  r.put('/:id', async (c) => {
    const id = c.req.param('id')
    const existing = db.get(id)
    if (!existing) return c.json({ message: 'Not found' }, 404)
    const body = await c.req.json().catch(() => null)
    if (!body || typeof body.title !== 'string' || typeof body.completed !== 'boolean') {
      return c.json({ message: 'title and completed are required' }, 400)
    }
    const now = new Date().toISOString()
    const todo: Todo = { ...existing, title: body.title.trim(), completed: body.completed, updatedAt: now }
    db.set(id, todo)
    return c.json(todo)
  })

  r.patch('/:id', async (c) => {
    const id = c.req.param('id')
    const existing = db.get(id)
    if (!existing) return c.json({ message: 'Not found' }, 404)
    const body = await c.req.json().catch(() => null)
    if (!body) return c.json({ message: 'Invalid JSON' }, 400)
    const now = new Date().toISOString()
    const todo: Todo = {
      ...existing,
      title: typeof body.title === 'string' ? body.title.trim() : existing.title,
      completed: typeof body.completed === 'boolean' ? body.completed : existing.completed,
      updatedAt: now,
    }
    db.set(id, todo)
    return c.json(todo)
  })

  r.delete('/:id', (c) => {
    const ok = db.delete(c.req.param('id'))
    return ok ? c.body(null, 204) : c.json({ message: 'Not found' }, 404)
  })

  return r
}

Then mount it in main.ts:

import { Hono } from 'https://deno.land/x/hono@v4.4.7/mod.ts'
import { logger } from 'https://deno.land/x/hono@v4.4.7/middleware/logger/index.ts'
import { cors } from 'https://deno.land/x/hono@v4.4.7/middleware/cors/index.ts'
import { createTodosRouter, type Todo } from './routes/todos.ts'

const app = new Hono()
const db = new Map<string, Todo>()

app.use('*', logger())
app.use('*', cors())
app.get('/health', (c) => c.json({ status: 'ok' }))

app.route('/api/v1/todos', createTodosRouter(db))

Deno.serve({ port: 8000 }, app.fetch)

4) Input validation

For small projects, basic checks are fine. For stronger validation, consider zod with Hono’s zod validator. You can add it later as a refinement.

5) Testing Hono routes

Hono lets you test without opening a port by calling app.request. Create tests/todos_test.ts:

// tests/todos_test.ts
import { Hono } from 'https://deno.land/x/hono@v4.4.7/mod.ts'
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/assert_equals.ts'

function buildApp() {
  const app = new Hono()
  const todos: { id: string; title: string }[] = []

  app.post('/api/v1/todos', async (c) => {
    const { title } = await c.req.json()
    const t = { id: crypto.randomUUID(), title }
    todos.push(t)
    return c.json(t, 201)
  })

  app.get('/api/v1/todos/:id', (c) => {
    const t = todos.find((x) => x.id === c.req.param('id'))
    return t ? c.json(t) : c.json({ message: 'Not found' }, 404)
  })

  return app
}

Deno.test('create and fetch a todo', async () => {
  const app = buildApp()

  const createRes = await app.request('/api/v1/todos', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title: 'Write tests' }),
  })
  assertEquals(createRes.status, 201)
  const created = await createRes.json() as { id: string }

  const getRes = await app.request(`/api/v1/todos/${created.id}`)
  assertEquals(getRes.status, 200)
})

Run tests:

deno test -A

6) Optional: add persistence with Deno KV

Deno KV is a built-in key-value store. Replace the in-memory Map with KV operations.

// kv.ts (optional helper)
import type { Todo } from './routes/todos.ts'

export async function createKV() {
  const kv = await Deno.openKv() // use default storage
  const PREFIX = ['todos'] as const

  return {
    async list(): Promise<Todo[]> {
      const iter = kv.list<Todo>({ prefix: PREFIX })
      const out: Todo[] = []
      for await (const entry of iter) out.push(entry.value)
      return out
    },
    async get(id: string): Promise<Todo | null> {
      const res = await kv.get<Todo>([...PREFIX, id])
      return res.value ?? null
    },
    async set(todo: Todo) {
      await kv.set([...PREFIX, todo.id], todo)
    },
    async delete(id: string): Promise<boolean> {
      const res = await kv.get([ ...PREFIX, id ])
      if (!res.value) return false
      await kv.delete([ ...PREFIX, id ])
      return true
    },
  }
}

Update your routes to call kv.list(), kv.get(), kv.set(), kv.delete() instead of Map. For example in POST:

const id = crypto.randomUUID()
const now = new Date().toISOString()
const todo: Todo = { id, title, completed: false, createdAt: now, updatedAt: now }
await kv.set(todo)
return c.json(todo, 201)

Notes:

  • Using Deno.openKv() with default storage usually needs no extra permissions, but if you pass a custom path, you may need file permissions.
  • For Deno Deploy, KV is backed by a managed service with the same API.

7) Configuration and scripts

Optional deno.json:

{
  "tasks": {
    "dev": "deno run -A --watch src/main.ts",
    "start": "deno run -A src/main.ts",
    "test": "deno test -A"
  },
  "fmt": {
    "useTabs": false,
    "lineWidth": 100
  },
  "lint": {
    "rules": { "tags": ["recommended"] }
  }
}

Run with:

deno task dev

8) Deployment

  • Deno Deploy

    • Push your repo to GitHub.
    • Create a new Deno Deploy project and link the repo.
    • Entry point: src/main.ts.
    • Set environment variables like PORT if needed; Deno Deploy provides a fetch handler automatically.
  • Self-host or containerize

    • Start with: deno run —allow-net —allow-env src/main.ts
    • Use a process manager or container and reverse proxy (Nginx, Caddy, or a managed platform).

9) API reference summary

  • GET /health
  • GET /api/v1/todos
  • GET /api/v1/todos/:id
  • POST /api/v1/todos
    • Body: { “title”: string }
  • PUT /api/v1/todos/:id
    • Body: { “title”: string, “completed”: boolean }
  • PATCH /api/v1/todos/:id
    • Body: any subset of { “title”: string, “completed”: boolean }
  • DELETE /api/v1/todos/:id

10) Production tips

  • Limit CORS to trusted origins instead of using wide-open defaults.
  • Return consistent problem details for errors.
  • Prefer structured logging in production.
  • Add request rate limiting in front of the API if exposed publicly.
  • Use Deno KV or an external database for persistence.
  • Add input validation with zod or Valibot when your schema grows.

You now have a fast, type-safe REST API running on Deno with Hono. From here you can add authentication, more resources, and persistence layers to fit your needs.