Bun + Hono + Neon + Upstash: Full Stack API on Vercel

In the previous post we deployed a Bun + Hono REST API to Vercel for free. It worked great — but all data was stored in memory. Restart the function and everything disappears.
In this post we fix that. We'll add two free-tier services:
- Neon — serverless PostgreSQL that scales to zero (free tier: 0.5 GB storage, unlimited connections)
- Upstash — serverless Redis for caching and rate limiting (free tier: 10,000 commands/day)
By the end you'll have a production-grade API with persistent storage, a query cache, and per-IP rate limiting — still entirely on the free tier.
What You'll Learn
✅ Connect Neon serverless PostgreSQL to a Hono API on Vercel
✅ Use Drizzle ORM for type-safe database queries and migrations
✅ Cache responses with Upstash Redis to cut database load
✅ Add per-IP rate limiting with Upstash
✅ Manage environment variables across local + Vercel
✅ Understand the free tier limits for Neon and Upstash
Architecture Overview
Every request first checks the rate limit in Redis. If the caller is within quota, we check the cache. A cache hit returns instantly without touching the database. A cache miss queries Neon, stores the result in Redis for the next request, and returns the data.
Prerequisites
- Completed Part 1: Deploy Bun + Hono to Vercel
- A free Neon account (sign up with GitHub)
- A free Upstash account (sign up with GitHub)
Part 1: Set Up Neon (Serverless PostgreSQL)
Step 1: Create a Neon Database
- Go to console.neon.tech and sign in
- Click New Project
- Name it
my-apiand choose a region close to your Vercel deployment (e.g.,us-east-1) - Click Create Project
Neon gives you a project with one database (neondb) and one branch (main). Think of Neon branches like Git branches — each has its own isolated database state, perfect for preview deployments.
- Copy the Connection string from the dashboard. It looks like:
postgresql://alex:AbC123dEf@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb?sslmode=requireStep 2: Install Dependencies
bun add drizzle-orm @neondatabase/serverless
bun add -d drizzle-kit- drizzle-orm — TypeScript ORM with first-class Neon support
- @neondatabase/serverless — Neon's HTTP-based driver (works on Edge Runtime, no TCP needed)
- drizzle-kit — CLI for migrations and schema introspection
Step 3: Create the Database Schema
Create the schema file:
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.$inferInsertStep 4: Create the 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 a lazy singleton — safe for serverless (no persistent connections)
export const db = getDb()
export { schema }Step 5: Configure Drizzle Kit
Create drizzle.config.ts in the project root:
// 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!,
},
})Add migration scripts to 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"
}
}Step 6: Run the First Migration
Add your database URL to .env.local:
# .env.local
DATABASE_URL=postgresql://alex:AbC123dEf@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb?sslmode=requireGenerate and apply the migration:
# Generate SQL migration files
bun run db:generate
# Apply migrations to Neon
bun run db:migrateYou'll see output like:
Applying migration 0000_initial_tasks.sql...
Migration applied successfullyVerify in Neon console → Tables — your tasks table should now exist.
Part 2: Set Up Upstash (Serverless Redis)
Step 7: Create an Upstash Redis Database
- Go to console.upstash.com and sign in
- Click Create Database
- Name it
my-api-cache - Choose Regional type, same region as your Neon database
- Click Create
From the database details page, copy two values:
- UPSTASH_REDIS_REST_URL (looks like
https://us1-xxxx.upstash.io) - UPSTASH_REDIS_REST_TOKEN (a long token string)
Step 8: Install the Upstash SDK
bun add @upstash/redis @upstash/ratelimit- @upstash/redis — HTTP-based Redis client (works on Edge Runtime)
- @upstash/ratelimit — Rate limiting built on top of Upstash Redis
Step 9: Create the 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 per 10 seconds per IP — adjust to your needs
export const rateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, '10 s'),
analytics: true, // Tracks usage in Upstash console
})Add the Upstash credentials to .env.local:
# .env.local
DATABASE_URL=postgresql://...
UPSTASH_REDIS_REST_URL=https://us1-xxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AXxxxxxxxxxxPart 3: Rewrite the Tasks Router with Real Database
Step 10: Update the Tasks Router
Replace the in-memory implementation with 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 // seconds
const createTaskSchema = z.object({
title: z.string().min(1).max(200),
})
const tasksRouter = new Hono()
// GET /api/tasks — list all (with Redis cache)
tasksRouter.get('/', async (c) => {
const cacheKey = 'tasks:all'
// Try cache first
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)
// Store in cache
await redis.set(cacheKey, tasks, { ex: CACHE_TTL })
return c.json({ tasks, total: tasks.length, source: 'db' })
})
// GET /api/tasks/:id — single task (with 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 — create a task
tasksRouter.post('/', zValidator('json', createTaskSchema), async (c) => {
const { title } = c.req.valid('json')
const [task] = await db
.insert(schema.tasks)
.values({ title })
.returning()
// Invalidate the list cache
await redis.del('tasks:all')
return c.json({ task }, 201)
})
// PATCH /api/tasks/:id — toggle 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()
// Invalidate both caches
await redis.del('tasks:all', `tasks:${id}`)
return c.json({ task })
})
// DELETE /api/tasks/:id — delete 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)
// Invalidate both caches
await redis.del('tasks:all', `tasks:${id}`)
return c.json({ task })
})
export default tasksRouterStep 11: Add Rate Limiting Middleware
Add a rate limiting middleware to the main app so it applies globally:
// 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 — runs before every route
app.use('*', async (c, next) => {
// Use IP from Vercel's forwarded header, fallback to a default
const ip = c.req.header('x-forwarded-for') ?? '127.0.0.1'
const { success, limit, remaining, reset } = await rateLimiter.limit(ip)
// Always expose rate limit headers so clients can back off gracefully
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 appPart 4: Test Locally
Step 12: Verify the Full Stack Works
Start the dev server:
bun run devTest the full flow:
# Create a task (writes to Neon, invalidates cache)
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Buy groceries"}'
# List tasks — first call hits Neon (source: "db")
curl http://localhost:3000/api/tasks
# List tasks again — hits Redis cache (source: "cache")
curl http://localhost:3000/api/tasks
# Check rate limit headers in the response
curl -v http://localhost:3000/api/health 2>&1 | grep -i "ratelimit"Expected response headers:
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 19
X-RateLimit-Reset: 1739268000000Part 5: Deploy to Vercel
Step 13: Add Environment Variables to Vercel
In your Vercel dashboard → Project → Settings → Environment Variables, add all three:
| Name | Value |
|---|---|
DATABASE_URL | Your Neon connection string |
UPSTASH_REDIS_REST_URL | Your Upstash REST URL |
UPSTASH_REDIS_REST_TOKEN | Your Upstash token |
Set each to Production, Preview, and Development environments.
Tip: Neon supports branching — use a separate Neon branch for Preview deployments. Create a
previewbranch in the Neon console and use its connection string for the Preview environment in Vercel.
Step 14: Run Migrations Before Deploying
The Drizzle migration needs to run once against your production database. Since Neon is already set up, run from your local machine (pointing at production):
# Temporarily point at production DB
DATABASE_URL="your-production-neon-url" bun run db:migrateAlternatively, add a postinstall script to auto-migrate on every deploy:
{
"scripts": {
"postinstall": "drizzle-kit migrate"
}
}Step 15: Push and Deploy
git add .
git commit -m "feat: add Neon database and Upstash caching"
git pushVercel auto-deploys. Your API is now live with persistent storage and Redis caching.
Part 6: Free Tier Limits
Understanding what each service provides for free:
Neon Free Tier
| Resource | Limit |
|---|---|
| Storage | 0.5 GB |
| Compute hours | 191.9 hours/month |
| Branches | 10 |
| Connections | Unlimited (pooled) |
| Projects | 1 |
| Auto-suspend | After 5 min inactivity |
Auto-suspend is the key Neon behavior to understand: when no queries run for 5 minutes, the compute pauses. The first query after a pause has a cold start of ~500ms. Subsequent queries are fast. For an API with moderate traffic this is invisible.
Upstash Free Tier
| Resource | Limit |
|---|---|
| Commands/day | 10,000 |
| Max data size | 256 MB |
| Max database size | 256 MB |
| Bandwidth | 200 MB/day |
| Databases | 1 |
With a 60-second cache TTL, one GET /api/tasks call costs 1 Redis command (the GET) for cached responses and 2 commands (the GET check + the SET) on a cache miss. At moderate traffic (a few hundred requests/day) you're well within the 10,000 command limit.
Vercel Free Tier (reminder)
| Resource | Limit |
|---|---|
| Edge invocations | 500,000/month |
| Bandwidth | 100 GB/month |
| Deployments | Unlimited |
Caching Strategy Explained
The cache invalidation pattern used in this guide is write-through invalidation:
Why delete on write instead of update? It's simpler and avoids stale cache bugs. The next read will rebuild the cache from fresh database data.
Cache key design:
| Key | What it caches | Invalidated by |
|---|---|---|
tasks:all | Full task list | Any write operation |
tasks:{id} | Single task | Update/delete of that task |
For more complex apps consider namespace-based invalidation — prefix keys with a version number (e.g., v1:tasks:all) and bump the version to invalidate everything at once.
Final Project Structure
my-api/
├── api/
│ └── index.ts # Vercel edge entry point
├── src/
│ ├── index.ts # Hono app + rate limiter
│ ├── db/
│ │ ├── index.ts # Drizzle client
│ │ └── schema.ts # Table definitions + types
│ ├── lib/
│ │ └── redis.ts # Upstash Redis + Ratelimit
│ └── routes/
│ └── tasks.ts # CRUD with DB + cache
├── drizzle/
│ └── 0000_initial_tasks.sql # Generated migration
├── drizzle.config.ts
├── .env.local # Local secrets (git-ignored)
├── package.json
└── tsconfig.jsonSummary and Key Takeaways
You now have a complete, production-grade API stack — all on free tiers:
✅ Neon stores data persistently with serverless PostgreSQL that scales to zero
✅ Drizzle ORM gives type-safe queries and auto-generated SQL migrations
✅ Upstash Redis caches read responses to minimize database hits
✅ Rate limiting protects the API from abuse with per-IP sliding windows
✅ Write-through cache invalidation keeps data consistent after every mutation
✅ Edge Runtime + HTTP-based drivers means no TCP connection issues on Vercel
✅ Zero-config CI/CD — push to GitHub, Vercel deploys automatically
Next Steps
- Add authentication with Hono's
hono/jwtmiddleware and store users in Neon - Add full-text search to the tasks list using PostgreSQL's
tsvectorandtsquery - Add pagination to
GET /api/taskswithLIMITandOFFSET - Explore Neon branching for isolated preview environments per PR
- Monitor Redis usage in the Upstash console analytics dashboard
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.