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
- Đã hoàn thành Phần 1: Deploy Bun + Hono lên Vercel
- Tài khoản Neon miễn phí (đăng ký bằng GitHub)
- Tài khoản Upstash miễn phí (đăng ký bằng GitHub)
Phần 1: Cài đặt Neon (Serverless PostgreSQL)
Bước 1: Tạo Neon Database
- Truy cập console.neon.tech và đăng nhập
- Nhấp New Project
- Đặt tên
my-apivà chọn region gần Vercel deployment của bạn (ví dụ:us-east-1) - 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.
- Sao chép Connection string từ dashboard. Nó trông như sau:
postgresql://alex:[email protected]/neondb?sslmode=requireBướ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.$inferInsertBướ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=requireTạ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:migrateBạn sẽ thấy output như sau:
Applying migration 0000_initial_tasks.sql...
Migration applied successfullyKiể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
- Truy cập console.upstash.com và đăng nhập
- Nhấp Create Database
- Đặt tên
my-api-cache - Chọn loại Regional, cùng region với Neon database của bạn
- 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=AXxxxxxxxxxxPhầ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 tasksRouterBướ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 appPhầ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 devTest 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: 1739268000000Phầ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ên | Giá trị |
|---|---|
DATABASE_URL | Connection string của Neon |
UPSTASH_REDIS_REST_URL | REST URL của Upstash |
UPSTASH_REDIS_REST_TOKEN | Token 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
previewtrong 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:migrateHoặ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 pushVercel 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ên | Giới hạn |
|---|---|
| Storage | 0.5 GB |
| Compute hours | 191.9 giờ/tháng |
| Branches | 10 |
| Connections | Không giới hạn (pooled) |
| Projects | 1 |
| Auto-suspend | Sau 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ên | Giới hạn |
|---|---|
| Commands/ngày | 10,000 |
| Kích thước data tối đa | 256 MB |
| Kích thước database tối đa | 256 MB |
| Bandwidth | 200 MB/ngày |
| Databases | 1 |
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ên | Giới hạn |
|---|---|
| Edge invocations | 500,000/tháng |
| Bandwidth | 100 GB/tháng |
| Deployments | Khô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:
| Key | Cache gì | Bị xóa bởi |
|---|---|---|
tasks:all | Toàn bộ danh sách task | Bấ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.jsonTó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/jwtcủa Hono và lưu users trong Neon - Thêm full-text search cho danh sách tasks dùng
tsvectorvàtsquerycủa PostgreSQL - Thêm pagination cho
GET /api/tasksvớiLIMITvà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.