Self-hosted URL shortener on the edge
Cloudflare D1 + KV + R2 + Workers Β· Next.js 15 Β· Railway
Zhe uses four Cloudflare services as its data plane, with a Next.js application on Railway as the control plane. A Cloudflare Worker sits at the edge as a transparent proxy, resolving short links from KV in under 1ms before falling back to the origin.
βββββββββββββββββββββββββββββββββββββββββββ
β Cloudflare Edge β
β β
User Request β ββββββββββββ ββββββββββββββββ β
ββββββββββββββββββΊ β β Worker βββββββΊβ KV Cache β β
zhe.to/abc β β zhe-edge β β slug β URL β β
β ββββββ¬ββββββ ββββββββββββββββ β
β β KV miss / reserved path β
ββββββββββΌβββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Railway Origin (Next.js) β
β β
β Middleware βββΊ LRU Cache βββΊ D1 β
β Server Actions βββΊ ScopedDB βββΊ D1 β
β Presigned URLs βββΊ R2 (S3 API) β
β Fire-and-forget βββΊ KV sync β
βββββββββββββββββββββββββββββββββββββββββββ
| Service | Role | Access Method |
|---|---|---|
| D1 (SQLite) | Primary database β links, analytics, users, folders, tags, uploads | REST API from Railway |
| KV | Edge cache β slug-to-URL mapping for sub-ms redirects | Worker binding (read) + REST API (write) |
| R2 (S3) | Object storage β file uploads, screenshots, temporary files | S3-compatible API via presigned URLs |
| Workers | Edge proxy β KV redirect, geo header mapping, cron triggers | zhe-edge deployed via Wrangler |
The read path is optimized for latency. Most clicks never leave the Cloudflare edge.
1. GET zhe.to/abc
β
2. Worker checks: root? static? reserved? multi-segment?
β β Yes: forward to origin
β β No: continue
β
3. KV.get("abc") β { id, originalUrl, expiresAt }
β
ββ HIT (not expired)
β β 307 redirect
β β waitUntil: POST /api/record-click (source: "worker")
β
ββ MISS / expired / error
β Forward to origin
β Middleware: LRU cache check (1000 entries, 60s TTL)
β LRU miss: D1 query via REST API
β 307 redirect
β waitUntil: recordClick (source: "origin")
Click analytics are always fire-and-forget β the 307 redirect is returned immediately, and the analytics POST happens asynchronously via waitUntil(). Every click is tagged with its resolution source (worker or origin), which doubles as a KV cache hit rate metric on the dashboard.
The write path goes through the Next.js origin and synchronizes to KV inline.
1. User submits URL in dashboard (or POST /api/link/create/{token})
β
2. Server Action: auth check β ScopedDB(userId)
β
3. Slug resolution: custom slug or auto-generate
β
4. D1 INSERT INTO links ... RETURNING *
β
5. Fire-and-forget (parallel, non-blocking):
βββ KV PUT slug β { id, originalUrl, expiresAt }
βββ Tag association (if provided)
βββ Metadata enrichment (fetch title, favicon, description)
β
6. Return link to client
KV is treated as a disposable cache β writes are fire-and-forget and never block the user action. On failure, the next click simply falls through to the D1 origin path. A full D1-to-KV sync runs on first dashboard visit after deploy as a consistency safety net.
The Worker resolves short links from KV at the edge without hitting the origin server. Each KV entry stores the minimum data needed for a redirect:
{
"id": 42,
"originalUrl": "https://example.com/very-long-url",
"expiresAt": 1735689600000
}Sync strategy: Write-through on every mutation (create, update, delete), plus a full bulk sync on deploy. No cron-based sync β KV consistency is maintained inline.
| Mutation | KV Action |
|---|---|
| Create link | PUT slug |
| Update link | PUT newSlug + DELETE oldSlug (if slug changed) |
| Delete link | DELETE slug |
The Worker also maps Cloudflare geo headers to Vercel-style headers so the origin's analytics code works identically regardless of whether traffic arrives via the Worker or directly:
| Cloudflare | Mapped To | Used By |
|---|---|---|
CF-IPCountry |
x-vercel-ip-country |
extractClickMetadata() |
request.cf.city |
x-vercel-ip-city |
extractClickMetadata() |
D1 is accessed via Cloudflare's REST API (the Next.js app runs on Railway, not on Workers, so there's no direct binding). All queries go through a single entry point with a 5-second timeout:
POST https://api.cloudflare.com/client/v4/accounts/{id}/d1/database/{id}/query
ScopedDB provides code-level row security. Constructing new ScopedDB(userId) binds the user ID once β every subsequent method automatically injects WHERE user_id = ?. This makes it structurally impossible to access another user's data:
const db = new ScopedDB(session.user.id)
const links = await db.getLinks() // WHERE user_id = ? is automatic
const folders = await db.getFolders() // same β no way to forgetAnalytics are scoped through JOINs (analytics JOIN links ON ... WHERE links.user_id = ?). D1's ~100 parameter limit is handled with automatic chunking.
R2 stores user-uploaded files, screenshots, and temporary files. User uploads use presigned URLs so large files go directly from the browser to R2 without passing through Railway:
1. Client requests upload URL (Server Action)
2. Server generates presigned PUT URL (5 min expiry)
3. Client PUTs file directly to R2
4. Client confirms upload (Server Action records metadata in D1)
Key structure:
{user-hash}/YYYYMMDD/{uuid}.{ext} # permanent uploads
tmp/{uuid}_{timestamp}.{ext} # temporary files (auto-cleaned)
User folders are isolated with a salted SHA-256 hash of the userId (first 12 hex chars). Temporary files are cleaned up by a Worker cron that runs every 30 minutes, deleting anything in the tmp/ prefix older than 1 hour.
The zhe-edge Worker runs a scheduled handler every 30 minutes:
| Schedule | Action | Purpose |
|---|---|---|
*/30 * * * * |
POST /api/cron/cleanup |
Delete expired temporary files from R2 |
KV sync is not cron-driven β it happens inline on every mutation and as a bulk safety net on deploy.
- Short links β custom or auto-generated slugs, expiration dates, notes, tags
- Click analytics β real-time tracking with device, browser, OS, country, city, referer breakdown
- Edge redirects β sub-millisecond resolution via Cloudflare KV at 300+ edge locations
- File uploads β share files via R2 with generated short links
- Folders & tags β organize links with nested folders and color-coded tags
- Inbox triage β review and organize newly created links
- Storage management β R2/D1 usage overview, orphan file detection, batch cleanup
- Overview dashboard β stat cards, click trends, top links, device/browser/file-type charts
- Global search β
Cmd+Kto search links and folders - Auto metadata β fetch title, description, favicon on link creation
- Webhook API β create links programmatically with token auth
- Dark mode β follows system theme
- Google OAuth β only authorized users can manage links
| Layer | Choice |
|---|---|
| Runtime | Bun |
| Framework | Next.js 15 (App Router) |
| Language | TypeScript (strict mode) |
| Database | Cloudflare D1 (SQLite at the edge) |
| ORM | Drizzle (schema & types only β queries are raw SQL) |
| Edge Cache | Cloudflare KV |
| Object Storage | Cloudflare R2 (S3-compatible) |
| Edge Proxy | Cloudflare Workers |
| UI | Tailwind CSS + shadcn/ui |
| Auth | Auth.js v5 (Google OAuth) |
| Testing | Vitest + React Testing Library + Playwright |
| Deployment | Railway (origin) + Cloudflare (edge) |
bun installcp .env.example .env.localEdit .env.local with the required variables:
| Variable | Description | Source |
|---|---|---|
AUTH_SECRET |
NextAuth.js secret | openssl rand -base64 32 |
AUTH_GOOGLE_ID |
Google OAuth client ID | Google Cloud Console |
AUTH_GOOGLE_SECRET |
Google OAuth client secret | Google Cloud Console |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare account ID | Cloudflare Dashboard β Overview |
CLOUDFLARE_D1_DATABASE_ID |
Production D1 database UUID | wrangler d1 list |
CLOUDFLARE_API_TOKEN |
API token with D1/KV/R2 permissions | Cloudflare Dashboard β API Tokens |
These variables are required for running tests. The pre-push hook will fail without them.
| Variable | Description | Source |
|---|---|---|
D1_TEST_DATABASE_ID |
Test D1 database UUID (must differ from prod) | wrangler d1 list (zhe-db-test) |
D1_TEST_PROXY_URL |
Test Worker URL (must contain "-test") | https://zhe-edge-test.xxx.workers.dev |
D1_TEST_PROXY_SECRET |
Test Worker D1 proxy secret | Same as test Worker's D1_PROXY_SECRET |
R2_TEST_BUCKET_NAME |
Test R2 bucket name | zhe-test |
R2_TEST_PUBLIC_DOMAIN |
Test R2 domain (placeholder OK) | https://test-r2.zhe.to |
KV_TEST_NAMESPACE_ID |
Test KV namespace ID | wrangler kv namespace list |
| Variable | Description |
|---|---|
D1_PROXY_URL |
Production Worker URL for dev server |
D1_PROXY_SECRET |
Production Worker D1 proxy secret |
See Getting Started for detailed setup instructions.
bun devVisit http://localhost:7006
bun run test:run # all unit/integration/component tests
bun run test:api # L2 API E2E (requires test env vars)
bun run test:e2e:pw # L3 Playwright E2E (requires test env vars)
bun run test:coverage # coverage report| Command | Description |
|---|---|
bun dev |
Dev server (port 7006) |
bun run build |
Production build |
bun run lint |
ESLint (zero-warning policy) |
bun run test:run |
All unit/integration/component tests |
bun run test:unit |
Unit tests only |
bun run test:unit:coverage |
Unit tests + coverage gate |
bun run test:api |
API E2E tests (mock-level) |
bun run test:e2e:pw |
Playwright BDD E2E (port 27006) |
bun run test:e2e:pw:ui |
Playwright UI mode |
bun run test:coverage |
Coverage report |
zhe/
βββ actions/ # Server Actions ('use server')
βββ app/ # Next.js App Router pages
β βββ (dashboard)/ # Dashboard route group
β βββ api/ # API routes (health, live, lookup, record-click, webhook, cron)
βββ components/ # React components
β βββ dashboard/ # Page-level components (links, overview, settings, storage, uploads, inbox)
β βββ ui/ # shadcn/ui primitives (auto-generated, do not edit)
βββ contexts/ # React Context (DashboardService)
βββ hooks/ # Shared React hooks
βββ lib/ # Shared utilities
β βββ db/ # D1 client, ScopedDB, schema
β βββ kv/ # KV client, sync logic
β βββ r2/ # R2 storage client (S3 API)
βββ models/ # Pure business logic (no React dependency)
βββ viewmodels/ # MVVM ViewModel hooks
βββ worker/ # Cloudflare Worker (zhe-edge) β standalone project
β βββ src/ # Worker source (fetch + scheduled handlers)
β βββ test/ # Worker unit tests
βββ tests/
β βββ unit/ # Unit tests
β βββ integration/ # Integration tests
β βββ components/ # Component tests
β βββ api/ # Vitest API E2E tests (mock-level)
β βββ playwright/ # Playwright browser E2E specs
βββ drizzle/ # Database migrations
βββ docs/ # Project documentation
βββ scripts/ # Build scripts
- Coverage target: statements >= 90%, functions >= 85%, branches >= 80%
- Zero-warning policy: ESLint
--max-warnings=0 - Git hooks (husky):
- pre-commit: L1 unit/integration tests + coverage gate + G1 typecheck/lint + G2 gitleaks
- pre-push: L2 API E2E + G2 osv-scanner (all hard gates)
- on-demand: L3 Playwright BDD E2E
| Layer | Tests | Gate | Hook |
|---|---|---|---|
| L1 | Unit + Integration | Hard | pre-commit |
| L2 | API E2E (real HTTP) | Hard | pre-push |
| L3 | Playwright BDD E2E | Manual | on-demand |
| G1 | TypeScript + ESLint | Hard | pre-commit |
| G2 | gitleaks + osv-scanner | Hard | pre-commit + pre-push |
| Port | Purpose |
|---|---|
| 7006 | Development server |
| 17006 | L2 API E2E server (auto-managed) |
| 27006 | L3 Playwright BDD E2E (auto-managed) |
| Doc | Content |
|---|---|
| Architecture | Layered design, data flow, core patterns |
| Getting Started | Dependencies, env vars, dev setup |
| Features | Short links, metadata, uploads, analytics |
| Database | Schema, ScopedDB, migrations |
| Testing | Coverage targets, mock strategy, TDD |
| Deployment | Railway, D1, security headers, domains |
| Contributing | Commit conventions, code quality |
| Performance | Caching, bundle optimization, runtime perf |
| E2E Coverage Analysis | E2E test coverage matrix, gap analysis |
| Backy Integration | Remote backup via Backy (push/pull) |
| Four-Layer Test Plan | Test architecture improvement plan & status |
MIT Β© 2026

