Back to blog

Bun + Hono + Neon + Upstash: Full Stack API trên Vercel

bunhonoverceltypescriptneonupstash
Bun + Hono + Neon + Upstash: Full Stack API trên Vercel

Trong bài trước chúng ta đã deploy một Bun + Hono REST API lên Vercel miễn phí. Nó hoạt động tốt — nhưng toàn bộ dữ liệu được lưu trong bộ nhớ. Khởi động lại function và mọi thứ biến mất.

Trong bài này chúng ta sẽ sửa điều đó. Chúng ta sẽ thêm hai dịch vụ free-tier:

  • Neon — serverless PostgreSQL tự động scale về 0 (free tier: 0.5 GB storage, unlimited connections)
  • Upstash — serverless Redis cho caching và rate limiting (free tier: 10,000 commands/ngày)

Cuối bài bạn sẽ có một API production-grade với persistent storage, query cache và rate limiting theo IP — vẫn hoàn toàn trên free tier.

Bạn sẽ học được gì

✅ Kết nối Neon serverless PostgreSQL vào Hono API trên Vercel
✅ Dùng Drizzle ORM cho các query database type-safe và migrations
✅ Cache response với Upstash Redis để giảm tải database
✅ Thêm rate limiting theo IP với Upstash
✅ Quản lý biến môi trường cho cả local lẫn Vercel
✅ Hiểu giới hạn free tier của Neon và Upstash


Tổng quan kiến trúc

Mỗi request trước tiên kiểm tra rate limit trong Redis. Nếu người gọi còn trong hạn mức, chúng ta kiểm tra cache. Cache hit trả về ngay lập tức mà không cần truy cập database. Cache miss sẽ query Neon, lưu kết quả vào Redis cho request tiếp theo và trả về dữ liệu.


Điều kiện tiên quyết


Phần 1: Cài đặt Neon (Serverless PostgreSQL)

Bước 1: Tạo Neon Database

  1. Truy cập console.neon.tech và đăng nhập
  2. Nhấp New Project
  3. Đặt tên my-api và chọn region gần Vercel deployment của bạn (ví dụ: us-east-1)
  4. Nhấp Create Project

Neon cho bạn một project với một database (neondb) và một branch (main). Hãy nghĩ Neon branches giống như Git branches — mỗi branch có database state riêng biệt, hoàn hảo cho preview deployments.

  1. Sao chép Connection string từ dashboard. Nó trông như sau:
postgresql://alex:[email protected]/neondb?sslmode=require

Bước 2: Cài đặt Dependencies

bun add drizzle-orm @neondatabase/serverless
bun add -d drizzle-kit
  • drizzle-orm — TypeScript ORM hỗ trợ Neon đầu tiên
  • @neondatabase/serverless — Driver HTTP-based của Neon (hoạt động trên Edge Runtime, không cần TCP)
  • drizzle-kit — CLI cho migrations và schema introspection

Bước 3: Tạo Database Schema

Tạo file schema:

mkdir -p src/db
touch src/db/schema.ts
// src/db/schema.ts
import { pgTable, text, boolean, timestamp, serial } from 'drizzle-orm/pg-core'
 
export const tasks = pgTable('tasks', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  done: boolean('done').notNull().default(false),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
 
export type Task = typeof tasks.$inferSelect
export type NewTask = typeof tasks.$inferInsert

Bước 4: Tạo Database Client

// src/db/index.ts
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'
import * as schema from './schema'
 
function getDb() {
  const url = process.env.DATABASE_URL
  if (!url) throw new Error('DATABASE_URL is not set')
  const sql = neon(url)
  return drizzle(sql, { schema })
}
 
// Export lazy singleton — an toàn cho serverless (không có persistent connections)
export const db = getDb()
export { schema }

Bước 5: Cấu hình Drizzle Kit

Tạo drizzle.config.ts tại thư mục gốc của project:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
 
export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})

Thêm migration scripts vào package.json:

{
  "scripts": {
    "dev": "bun run --hot src/index.ts",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

Bước 6: Chạy Migration đầu tiên

Thêm database URL vào .env.local:

# .env.local
DATABASE_URL=postgresql://alex:[email protected]/neondb?sslmode=require

Tạo và áp dụng migration:

# Tạo các file SQL migration
bun run db:generate
 
# Áp dụng migrations lên Neon
bun run db:migrate

Bạn sẽ thấy output như sau:

Applying migration 0000_initial_tasks.sql...
Migration applied successfully

Kiểm tra trong Neon console → Tables — bảng tasks của bạn đã tồn tại.


Phần 2: Cài đặt Upstash (Serverless Redis)

Bước 7: Tạo Upstash Redis Database

  1. Truy cập console.upstash.com và đăng nhập
  2. Nhấp Create Database
  3. Đặt tên my-api-cache
  4. Chọn loại Regional, cùng region với Neon database của bạn
  5. Nhấp Create

Từ trang chi tiết database, sao chép hai giá trị:

  • UPSTASH_REDIS_REST_URL (dạng như https://us1-xxxx.upstash.io)
  • UPSTASH_REDIS_REST_TOKEN (một chuỗi token dài)

Bước 8: Cài đặt Upstash SDK

bun add @upstash/redis @upstash/ratelimit
  • @upstash/redis — HTTP-based Redis client (hoạt động trên Edge Runtime)
  • @upstash/ratelimit — Rate limiting được xây dựng trên Upstash Redis

Bước 9: Tạo Redis Client

// src/lib/redis.ts
import { Redis } from '@upstash/redis'
import { Ratelimit } from '@upstash/ratelimit'
 
export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
 
// 20 requests mỗi 10 giây mỗi IP — điều chỉnh theo nhu cầu
export const rateLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(20, '10 s'),
  analytics: true, // Theo dõi usage trong Upstash console
})

Thêm Upstash credentials vào .env.local:

# .env.local
DATABASE_URL=postgresql://...
UPSTASH_REDIS_REST_URL=https://us1-xxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AXxxxxxxxxxx

Phần 3: Viết lại Tasks Router với Database thực

Bước 10: Cập nhật Tasks Router

Thay thế implementation in-memory bằng Neon + Drizzle:

// src/routes/tasks.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { db, schema } from '../db'
import { redis } from '../lib/redis'
 
const CACHE_TTL = 60 // giây
 
const createTaskSchema = z.object({
  title: z.string().min(1).max(200),
})
 
const tasksRouter = new Hono()
 
// GET /api/tasks — lấy danh sách (với Redis cache)
tasksRouter.get('/', async (c) => {
  const cacheKey = 'tasks:all'
 
  // Thử cache trước
  const cached = await redis.get<schema.Task[]>(cacheKey)
  if (cached) {
    return c.json({ tasks: cached, total: cached.length, source: 'cache' })
  }
 
  // Cache miss → query database
  const tasks = await db.select().from(schema.tasks).orderBy(schema.tasks.createdAt)
 
  // Lưu vào cache
  await redis.set(cacheKey, tasks, { ex: CACHE_TTL })
 
  return c.json({ tasks, total: tasks.length, source: 'db' })
})
 
// GET /api/tasks/:id — một task (với Redis cache)
tasksRouter.get('/:id', async (c) => {
  const id = Number(c.req.param('id'))
  if (isNaN(id)) return c.json({ error: 'Invalid ID' }, 400)
 
  const cacheKey = `tasks:${id}`
  const cached = await redis.get<schema.Task>(cacheKey)
  if (cached) {
    return c.json({ task: cached, source: 'cache' })
  }
 
  const [task] = await db
    .select()
    .from(schema.tasks)
    .where(eq(schema.tasks.id, id))
 
  if (!task) return c.json({ error: 'Task not found' }, 404)
 
  await redis.set(cacheKey, task, { ex: CACHE_TTL })
  return c.json({ task, source: 'db' })
})
 
// POST /api/tasks — tạo task mới
tasksRouter.post('/', zValidator('json', createTaskSchema), async (c) => {
  const { title } = c.req.valid('json')
 
  const [task] = await db
    .insert(schema.tasks)
    .values({ title })
    .returning()
 
  // Xóa cache danh sách
  await redis.del('tasks:all')
 
  return c.json({ task }, 201)
})
 
// PATCH /api/tasks/:id — chuyển đổi trạng thái done
tasksRouter.patch('/:id', async (c) => {
  const id = Number(c.req.param('id'))
  if (isNaN(id)) return c.json({ error: 'Invalid ID' }, 400)
 
  const [existing] = await db
    .select()
    .from(schema.tasks)
    .where(eq(schema.tasks.id, id))
 
  if (!existing) return c.json({ error: 'Task not found' }, 404)
 
  const [task] = await db
    .update(schema.tasks)
    .set({ done: !existing.done, updatedAt: new Date() })
    .where(eq(schema.tasks.id, id))
    .returning()
 
  // Xóa cả hai cache
  await redis.del('tasks:all', `tasks:${id}`)
 
  return c.json({ task })
})
 
// DELETE /api/tasks/:id — xóa task
tasksRouter.delete('/:id', async (c) => {
  const id = Number(c.req.param('id'))
  if (isNaN(id)) return c.json({ error: 'Invalid ID' }, 400)
 
  const [task] = await db
    .delete(schema.tasks)
    .where(eq(schema.tasks.id, id))
    .returning()
 
  if (!task) return c.json({ error: 'Task not found' }, 404)
 
  // Xóa cả hai cache
  await redis.del('tasks:all', `tasks:${id}`)
 
  return c.json({ task })
})
 
export default tasksRouter

Bước 11: Thêm Rate Limiting Middleware

Thêm middleware rate limiting vào app chính để áp dụng toàn cục:

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import tasksRouter from './routes/tasks'
import { rateLimiter } from './lib/redis'
 
const app = new Hono().basePath('/api')
 
app.use('*', logger())
app.use('*', cors())
 
// Rate limiting — chạy trước mọi route
app.use('*', async (c, next) => {
  // Dùng IP từ header forwarded của Vercel, fallback về mặc định
  const ip = c.req.header('x-forwarded-for') ?? '127.0.0.1'
  const { success, limit, remaining, reset } = await rateLimiter.limit(ip)
 
  // Luôn expose các header rate limit để client có thể tự điều chỉnh
  c.header('X-RateLimit-Limit', String(limit))
  c.header('X-RateLimit-Remaining', String(remaining))
  c.header('X-RateLimit-Reset', String(reset))
 
  if (!success) {
    return c.json(
      { error: 'Too many requests. Please slow down.' },
      429,
    )
  }
 
  return next()
})
 
app.route('/tasks', tasksRouter)
 
app.get('/health', async (c) => {
  return c.json({
    status: 'ok',
    runtime: 'edge',
    timestamp: new Date().toISOString(),
  })
})
 
app.notFound((c) => c.json({ error: 'Not found' }, 404))
 
export default app

Phần 4: Test trên local

Bước 12: Xác nhận Full Stack hoạt động

Khởi động dev server:

bun run dev

Test toàn bộ flow:

# Tạo task (ghi vào Neon, xóa cache)
curl -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Mua đồ ăn"}'
 
# Lấy danh sách tasks — lần đầu hit Neon (source: "db")
curl http://localhost:3000/api/tasks
 
# Lấy danh sách tasks lần nữa — hit Redis cache (source: "cache")
curl http://localhost:3000/api/tasks
 
# Kiểm tra các rate limit header trong response
curl -v http://localhost:3000/api/health 2>&1 | grep -i "ratelimit"

Kết quả mong đợi trong response headers:

X-RateLimit-Limit: 20
X-RateLimit-Remaining: 19
X-RateLimit-Reset: 1739268000000

Phần 5: Deploy lên Vercel

Bước 13: Thêm biến môi trường vào Vercel

Trong Vercel dashboard → Project → Settings → Environment Variables, thêm cả ba biến:

TênGiá trị
DATABASE_URLConnection string của Neon
UPSTASH_REDIS_REST_URLREST URL của Upstash
UPSTASH_REDIS_REST_TOKENToken của Upstash

Đặt mỗi biến cho môi trường Production, Preview và Development.

Mẹo: Neon hỗ trợ branching — dùng Neon branch riêng cho Preview deployments. Tạo branch preview trong Neon console và dùng connection string của nó cho môi trường Preview trong Vercel.

Bước 14: Chạy Migrations trước khi Deploy

Migration Drizzle cần chạy một lần trên production database. Vì Neon đã được cài đặt, chạy từ máy local (trỏ vào production):

# Tạm thời trỏ vào production DB
DATABASE_URL="your-production-neon-url" bun run db:migrate

Hoặc thêm script postinstall để tự migrate mỗi lần deploy:

{
  "scripts": {
    "postinstall": "drizzle-kit migrate"
  }
}

Bước 15: Push và Deploy

git add .
git commit -m "feat: add Neon database and Upstash caching"
git push

Vercel tự động deploy. API của bạn giờ đã live với persistent storage và Redis caching.


Phần 6: Giới hạn Free Tier

Hiểu những gì mỗi dịch vụ cung cấp miễn phí:

Neon Free Tier

Tài nguyênGiới hạn
Storage0.5 GB
Compute hours191.9 giờ/tháng
Branches10
ConnectionsKhông giới hạn (pooled)
Projects1
Auto-suspendSau 5 phút không hoạt động

Auto-suspend là hành vi quan trọng nhất của Neon cần hiểu: khi không có query nào chạy trong 5 phút, compute tạm dừng. Query đầu tiên sau khi tạm dừng có cold start khoảng ~500ms. Các query tiếp theo nhanh bình thường. Với API có traffic vừa phải điều này gần như không nhận thấy.

Upstash Free Tier

Tài nguyênGiới hạn
Commands/ngày10,000
Kích thước data tối đa256 MB
Kích thước database tối đa256 MB
Bandwidth200 MB/ngày
Databases1

Với cache TTL 60 giây, một lần gọi GET /api/tasks tốn 1 Redis command (lệnh GET) cho cached responses và 2 commands (kiểm tra GET + lệnh SET) khi cache miss. Với traffic vừa phải (vài trăm requests/ngày) bạn hoàn toàn nằm trong giới hạn 10,000 command.

Vercel Free Tier (nhắc lại)

Tài nguyênGiới hạn
Edge invocations500,000/tháng
Bandwidth100 GB/tháng
DeploymentsKhông giới hạn

Giải thích Caching Strategy

Pattern cache invalidation được dùng trong hướng dẫn này là write-through invalidation:

Tại sao xóa khi write thay vì cập nhật? Đơn giản hơn và tránh lỗi cache cũ. Lần đọc tiếp theo sẽ xây dựng lại cache từ dữ liệu database mới nhất.

Thiết kế cache key:

KeyCache gìBị xóa bởi
tasks:allToàn bộ danh sách taskBất kỳ thao tác ghi nào
tasks:{id}Một task cụ thểCập nhật/xóa task đó

Với app phức tạp hơn hãy cân nhắc namespace-based invalidation — thêm prefix số phiên bản vào key (ví dụ: v1:tasks:all) và tăng version để xóa tất cả cùng lúc.


Cấu trúc dự án cuối cùng

my-api/
├── api/
│   └── index.ts              # Entry point Vercel edge
├── src/
│   ├── index.ts              # Hono app + rate limiter
│   ├── db/
│   │   ├── index.ts          # Drizzle client
│   │   └── schema.ts         # Định nghĩa table + types
│   ├── lib/
│   │   └── redis.ts          # Upstash Redis + Ratelimit
│   └── routes/
│       └── tasks.ts          # CRUD với DB + cache
├── drizzle/
│   └── 0000_initial_tasks.sql  # Migration được tạo tự động
├── drizzle.config.ts
├── .env.local                # Secrets local (git-ignored)
├── package.json
└── tsconfig.json

Tóm tắt và Bài học chính

Bạn đã có một API stack hoàn chỉnh, production-grade — tất cả trên free tier:

✅ Neon lưu trữ dữ liệu bền vững với serverless PostgreSQL tự động scale về 0
✅ Drizzle ORM cung cấp query type-safe và SQL migrations tự động tạo
✅ Upstash Redis cache read responses để giảm thiểu truy cập database
✅ Rate limiting bảo vệ API khỏi lạm dụng với sliding window theo IP
✅ Write-through cache invalidation giữ dữ liệu nhất quán sau mỗi mutation
✅ Edge Runtime + HTTP-based drivers không có vấn đề TCP connection trên Vercel
✅ Zero-config CI/CD — push lên GitHub, Vercel tự động deploy

Bước tiếp theo

  • Thêm authentication với middleware hono/jwt của Hono và lưu users trong Neon
  • Thêm full-text search cho danh sách tasks dùng tsvector và tsquery của PostgreSQL
  • Thêm pagination cho GET /api/tasks với LIMIT và OFFSET
  • Khám phá Neon branching cho môi trường preview riêng biệt mỗi PR
  • Theo dõi Redis usage trong analytics dashboard của Upstash console

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.