One API, two platforms

The same Hono app on Vercel and on Cloudflare Workers.

What it is

Hono is built on web standards: a handler takes a Request and returns a Response, the same objects browsers use. So the API behind this lab runs unchanged in two very different places. On Vercel it's a Node.js function in one region (Washington, D.C., next to the database), mounted into Next.js. On Cloudflare Workers (api-cf.djmiskey.com) it runs in V8 isolates in hundreds of cities, starting in milliseconds. Both talk to the same Neon Postgres over HTTP, so they share every task, key and score. What differs is configuration, not code: how secrets arrive, which header carries the client's IP, and the limits each platform sets.

When to use it

  • Avoiding lock-in: keep the API portable, choose the host later
  • Running near users (Workers) or near the data (one region)
  • Moving an API between platforms without a rewrite
Official docs ↗

1. Ask both

"To the database" is measured inside each copy: a select 1 to Neon in AWS us-east-1. The Vercel function sits in the same region, a millisecond or two away. A Worker starts in the Cloudflare city nearest you, so from far away every query crosses an ocean, and a request makes several. Smart Placement is on: once Cloudflare has seen enough traffic, it runs the Worker next to the database instead (the cf-placement response header says remote-… when it does).

2. What changed between the two

  • Entry point: Vercel mounts the app with hono/vercel in a Next.js route; the Worker exports { fetch } that calls app.fetch (below).
  • Client IP: x-real-ip is set by Vercel's network; on Cloudflare the Worker copies CF-Connecting-IP into it, overwriting anything the client sent, so the rate limiter can't be fooled on either.
  • Configuration: both read process.env. On Workers that needs the nodejs_compat flag; settings live in wrangler.jsonc, secrets are set with wrangler secret put.
  • Bundle: PGlite (the local database, WebAssembly) is swapped for a stub, since the Worker always has Neon.
  • Limits (Free plans): Workers allow 10 ms of CPU and 50 outgoing requests per request, so every database query counts. The Worker skips the create-table statements and keeps live streams to 15 seconds. Vercel functions run up to 300 s with more CPU, but in one region.
  • Not portable: the pages. They're Next.js on Vercel; the Worker serves only /api.
Show code · worker/index.ts
import type { ExecutionContext } from "hono";
import { app } from "../src/api/app";

// The same Hono app as on Vercel (src/api/app.ts), deployed as a Cloudflare
// Worker. Hono only deals in standard Request and Response objects, so the
// whole entry point is this one function. The rest of the difference lives
// in wrangler.jsonc: environment variables, secrets, and an alias that keeps
// PGlite (the local database) out of the bundle.
const worker = {
  fetch(request: Request, env: unknown, ctx: ExecutionContext) {
    // Only the API runs here; the pages are on Vercel. Someone opening the
    // bare domain gets the page that explains this copy, not a 404.
    if (new URL(request.url).pathname === "/") return Response.redirect("https://api.djmiskey.com/portability", 302);
    // Rate limits key anonymous clients by x-real-ip. On Vercel its network
    // sets that header; here Cloudflare's equivalent is CF-Connecting-IP, so
    // copy it over, replacing anything the client sent itself.
    const headers = new Headers(request.headers);
    headers.set("x-real-ip", request.headers.get("cf-connecting-ip") ?? "unknown");
    headers.delete("x-forwarded-for");
    return app.fetch(new Request(request, { headers }), env, ctx);
  },
};

export default worker;
Show code · wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  // The API alone (the pages stay on Vercel), as a second deployment of the
  // same code: https://api-cf.djmiskey.com. Deploy with `npm run worker:deploy`.
  "name": "api-feature-lab",
  "main": "worker/index.ts",
  "compatibility_date": "2026-10-01",
  // nodejs_compat: process.env is filled from the vars and secrets below, so
  // the app reads its configuration the same way on both platforms.
  "compatibility_flags": ["nodejs_compat"],

  // Only the Custom Domain, so Cloudflare creates its DNS record and
  // certificate; no workers.dev URL, no preview URLs.
  "workers_dev": false,
  "preview_urls": false,
  "routes": [{ "pattern": "api-cf.djmiskey.com", "custom_domain": true }],

  // Smart Placement: run the Worker near what it talks to most (Neon, in
  // AWS us-east-1) rather than near each visitor. Every request makes several
  // database round trips, so being next to the database matters more.
  "placement": { "mode": "smart" },

  "alias": { "@electric-sql/pglite": "./worker/no-pglite.ts" },

  // Plain settings. Secrets (DATABASE_URL, JWT_SECRET) are set with
  // `npx wrangler secret put`, never written here.
  "vars": {
    "LAB_PLATFORM": "cloudflare-workers",
    // The tables already exist (Vercel's deployment created them): skip the
    // create-if-missing statements, which would cost a dozen subrequests on
    // every new isolate (the Free plan allows 50 per request).
    "LAB_SCHEMA": "skip",
    // Shorter live streams: each poll is a database subrequest.
    "LAB_SSE_SECONDS": "15"
  },

  "observability": { "enabled": true }
}
Show code · src/app/api/[[...route]]/route.ts
import { handle } from "hono/vercel";
import { app } from "@/api/app";

// Next.js hands every /api/* request to the Hono app. One handler per HTTP
// method; Hono does the routing from there.
const handler = handle(app);
export { handler as DELETE, handler as GET, handler as HEAD, handler as OPTIONS, handler as PATCH, handler as POST, handler as PUT };