Back to blog

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

bunhonoverceltypescriptneonupstash
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


Part 1: Set Up Neon (Serverless PostgreSQL)

Step 1: Create a Neon Database

  1. Go to console.neon.tech and sign in
  2. Click New Project
  3. Name it my-api and choose a region close to your Vercel deployment (e.g., us-east-1)
  4. 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.

  1. 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=require

Step 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.$inferInsert

Step 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=require

Generate and apply the migration:

# Generate SQL migration files
bun run db:generate
 
# Apply migrations to Neon
bun run db:migrate

You'll see output like:

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

Verify in Neon console → Tables — your tasks table should now exist.


Part 2: Set Up Upstash (Serverless Redis)

Step 7: Create an Upstash Redis Database

  1. Go to console.upstash.com and sign in
  2. Click Create Database
  3. Name it my-api-cache
  4. Choose Regional type, same region as your Neon database
  5. 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=AXxxxxxxxxxx

Part 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 tasksRouter

Step 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 app

Part 4: Test Locally

Step 12: Verify the Full Stack Works

Start the dev server:

bun run dev

Test 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: 1739268000000

Part 5: Deploy to Vercel

Step 13: Add Environment Variables to Vercel

In your Vercel dashboard → Project → Settings → Environment Variables, add all three:

NameValue
DATABASE_URLYour Neon connection string
UPSTASH_REDIS_REST_URLYour Upstash REST URL
UPSTASH_REDIS_REST_TOKENYour Upstash token

Set each to Production, Preview, and Development environments.

Tip: Neon supports branching — use a separate Neon branch for Preview deployments. Create a preview branch 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:migrate

Alternatively, 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 push

Vercel 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

ResourceLimit
Storage0.5 GB
Compute hours191.9 hours/month
Branches10
ConnectionsUnlimited (pooled)
Projects1
Auto-suspendAfter 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

ResourceLimit
Commands/day10,000
Max data size256 MB
Max database size256 MB
Bandwidth200 MB/day
Databases1

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)

ResourceLimit
Edge invocations500,000/month
Bandwidth100 GB/month
DeploymentsUnlimited

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:

KeyWhat it cachesInvalidated by
tasks:allFull task listAny write operation
tasks:{id}Single taskUpdate/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.json

Summary 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/jwt middleware and store users in Neon
  • Add full-text search to the tasks list using PostgreSQL's tsvector and tsquery
  • Add pagination to GET /api/tasks with LIMIT and OFFSET
  • 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.