Building a REST API from Scratch with Deno and Hono
#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.