How We Built 1URL.at on Cloudflare Workers
A technical deep-dive into how 1URL.at is built on Cloudflare Workers, D1, KV, R2, and Workers Analytics Engine — one runtime, no traditional servers.
1URL.at is a URL shortener with link-in-bio pages, cookieless analytics, and a full visual builder. It runs entirely on Cloudflare Workers. No traditional server, no separate database host, no Redis, no Vercel, no managed Node.js cluster. The entire stack — API, frontend serving, public redirect hot path, analytics write, OG image generation, and asset storage — runs at Cloudflare's edge.
This post explains the architecture, the decisions that led to it, and the specific Cloudflare primitives that make it work. If you are considering building on Cloudflare Workers, this is the kind of write-up I was looking for before we started.
Why Cloudflare Workers
The pitch for Cloudflare Workers is simple: your code runs in ~300 data centers globally, cold starts are sub-millisecond (V8 isolates, not containers), and you pay per request rather than for idle compute. For a URL shortener, where the performance of the redirect hot path is directly felt by end users, edge execution is not a nice-to-have — it is the product.
The constraint is that Workers runs in a V8 isolate, not Node.js. You get the Web Crypto API, the Fetch API, and a curated set of platform APIs, but not the Node.js standard library. We enable nodejs_compat in wrangler.json for the small subset of Node.js APIs we need (primarily stream compatibility for certain dependencies), but the codebase is written against the Workers API surface first.
Runtime and framework
The backend is a single Worker entry point (src/worker/index.ts) that handles all HTTP traffic:
- Short link redirects (
/r/:slug,/a/:slug,/:usernamePrefix/:slug) - API routes (
/api/*) - Static asset serving for the React SPA
- OG image generation
- Cron triggers
We use Hono for routing and middleware. Hono is a fast, lightweight router built specifically for edge runtimes — it has no Node.js dependencies and compiles to under 15kb. It supports middleware chaining, typed contexts, and route groups, which let us organise the API into logical routers (urls, link-pages, link-items, builder, auth, upload, admin, public, themes) without any coupling between them.
The middleware stack on /api/* looks like this, applied top-down:
// Global middleware (all routes)
app.use('*', errorMiddleware);
app.use('*', corsMiddleware);
app.use('*', securityHeadersMiddleware);
app.use('*', loggerMiddleware);
// API-only middleware
app.use('/api/*', rateLimitMiddleware);
app.use('/api/*', betterAuthMiddleware);
app.use('/api/*', rateLimitMiddleware); // second tier, post-auth
app.use('/api/*', csrfMiddleware);
Double rate limiting is intentional: the first pass handles unauthenticated brute-force, the second pass (after auth) handles authenticated abuse. CSRF runs last because it only applies to state-changing requests from the browser.
Frontend architecture
The frontend is a React 19 application built with Vite. In production, Vite produces a dist/ folder of static assets that the Worker serves via Cloudflare's asset binding (ASSETS). The Worker handles API routes and redirects on its own; everything else falls through to the ASSETS binding, which serves the SPA and returns index.html for unknown paths.
// SPA fallback in the Worker
app.get('*', async (c) => {
return c.env.ASSETS.fetch(c.req.raw);
});
The public link pages (/p/:slug and /:usernamePrefix/:slug) are rendered server-side using a separate Vite SSR bundle. This is for performance and SEO: a link page that is visited by someone who tapped your Instagram bio needs to load fast, and the <title> and meta tags need to be populated for social previews. SSR lets us inject the link page's data at render time without a client-side data fetch.
Development uses two separate processes: wrangler dev for the Worker (with local D1, KV, and R2 emulation), and Vite in HMR mode proxying API requests to the Worker. This gives fast frontend iteration without rebuilding the Worker on every change.
Data layer: D1, KV, and R2
D1
D1 is Cloudflare's SQLite-based database. Every persistent entity lives here: users, URL records, link pages, link items, themes, analytics rollups, auth sessions, and idempotency records.
We use Drizzle ORM for schema management and query building. Drizzle generates SQL migration files from the schema; we run db:generate after schema changes and apply migrations via wrangler d1 migrations apply. Drizzle's type inference means every query result is fully typed — no any at the database boundary.
A sample schema definition:
export const urls = sqliteTable('urls', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
shortName: text('short_name').notNull(),
destination: text('destination').notNull(),
routeType: text('route_type', { enum: ['global', 'prefix'] }).notNull().default('global'),
clickCount: integer('click_count').notNull().default(0),
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
expiresAt: integer('expires_at'),
createdAt: integer('created_at').notNull().default(sql`(unixepoch() * 1000)`),
});
D1's sqlite engine means we can use CTEs, window functions, and INSERT ... ON CONFLICT DO UPDATE — all standard SQLite features. The ON CONFLICT DO UPDATE pattern is particularly important for the analytics upsert path (see below).
D1 has a parameter limit of 100 bound parameters per statement. For batch inserts with multiple columns, this caps batch size. We use db.$client.batch() to send multiple prepared statements in a single round-trip when inserting large batches.
KV
Cloudflare KV is a globally replicated key-value store. We use it for:
- Sessions (Better Auth session data, keyed by session token).
- Rate limiting (sliding window counters, keyed by IP + route).
- Cache (OG images, the global homepage counter, analytics daily salts).
KV is eventually consistent — writes propagate to all edges within a few seconds. For session data, this means a user who logs in at one edge might briefly be unauthenticated at another edge. In practice this is not observable because the same visitor routes to the same Cloudflare POP on subsequent requests. For rate limiting, eventual consistency is acceptable — a small over-count or under-count on the limiter does not matter.
R2
R2 is Cloudflare's S3-compatible object store. We use it for user-uploaded assets: profile images, background images, and file attachments in link pages. R2 has no egress fees, which matters for image-heavy link pages.
The upload flow:
- The client requests a pre-signed upload token from
/api/builder/upload-asset. - The Worker validates the file type (magic bytes, not just extension), checks storage quotas, and writes the asset to R2.
- The Worker returns the public R2 URL.
- The client stores the URL in the link page's
profileImageUrlorbackgroundAssetIdfield.
Free users are capped at 250MB total storage across their assets. We enforce this with a pre-check plus a post-write rollback (count the assets again after the write, delete the just-uploaded asset if the limit was exceeded by a race condition).
Authentication
Auth is handled by Better Auth, a TypeScript-first auth framework. Better Auth handles:
- Email/password signup and login with hashed passwords.
- Session management (sessions stored in D1, with token in a
HttpOnlycookie). - Password reset flows with time-limited tokens.
Better Auth is mounted at /api/auth/* and runs inside the Worker. There is no separate auth service. The CSRF protection layer runs on top of Better Auth — every non-GET request from the React app must include an X-CSRF-Token header that matches the server-set cookie, preventing cross-site request forgery without requiring a separate CSRF library.
We added a legacy password migration layer (password_migration table + password-migration-service.ts) to handle users who originally signed up with bcryptjs-hashed passwords from an early version of the codebase. On login, if Better Auth's native verification fails, we try the bcryptjs path, and if that succeeds, we upgrade the hash to Better Auth's format transparently. The bcryptjs dependency is tree-shaken in production — it only loads on the migration code path.
Analytics pipeline: Workers Analytics Engine
The analytics pipeline is the most architecture-intensive part of the system. The goal is click tracking with no analytics cookies and no retained raw IP addresses or user-agent strings, while keeping the write path inexpensive.
Workers Analytics Engine (WAE)
WAE is Cloudflare's time-series analytics product. You write events by calling env.ANALYTICS.writeDataPoint() from within a Worker request — it is non-blocking and adds essentially zero latency to the redirect. Events are stored in WAE's internal time-series store and accessible via a SQL-like query API.
Each click event writes:
env.ANALYTICS.writeDataPoint({
indexes: [userId], // used for filtering by owner
blobs: [
entityType, // 'url' | 'link_page' | 'link_item'
entityId, // the entity's DB id
country, // from CF-IPCountry header
device, // 'mobile' | 'tablet' | 'desktop'
hashedVisitor, // daily-rotating SHA-256 hash
],
doubles: [1], // the click count (always 1)
});
The hashedVisitor is the cookieless unique visitor identifier: a SHA-256 digest of ${dailySalt}:${ip}:${userAgent}. The salt is a 32-byte random value generated once per UTC day and stored in KV with a 25-hour TTL. Raw IPs and user-agent strings are consumed only during hashing and are never written to any storage.
Hourly rollup to D1
WAE stores data for queries but is not the right place for dashboard-facing reads. We run an hourly cron (0 * * * *) that queries WAE's SQL API, aggregates clicks by (user_id, entity_type, entity_id, day, country), and upserts the results into the clicks_daily table in D1.
The rollup query to WAE:
const waeSql = `
SELECT
blob1 AS entity_type,
blob2 AS entity_id,
index1 AS user_id,
toStartOfInterval(timestamp, INTERVAL '1' HOUR) AS hour_bucket,
blob3 AS country,
SUM(_sample_interval) AS clicks
FROM ${dataset}
WHERE timestamp >= toDateTime(${watermarkTs})
AND timestamp < toDateTime(${currentHourTs})
GROUP BY entity_type, entity_id, user_id, hour_bucket, country
`;
SUM(_sample_interval) instead of COUNT(*) is important: WAE auto-samples at high volume and sets _sample_interval to the inverse sampling rate. Summing it instead of counting rows gives the correct extrapolated event count.
The upsert into D1:
const upsert = db.prepare(`
INSERT INTO clicks_daily (user_id, entity_type, entity_id, day, country, clicks)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, entity_type, entity_id, day, country)
DO UPDATE SET clicks = clicks_daily.clicks + excluded.clicks
`);
This is a single-phase atomic upsert — no pre-flight SELECT. The hourly watermark (stored in KV) prevents double-processing the same hour. The combination of watermark idempotency and the additive UPSERT means the rollup can be re-run safely without double-counting.
We batch the upserts in groups of 16 rows per prepared statement (D1's 100-parameter limit divided by 6 columns per row) and send multiple batches in a single db.$client.batch() call.
Local development analytics
WAE is only available in production. In local development (ENVIRONMENT=development), we bypass WAE entirely and write directly to D1's clicks_daily table via a writeClicksDailyDirect() function that runs the same upsert SQL. The local analytics response is immediate rather than waiting for the hourly rollup.
The environment check:
function isProductionLikeEnvironment(env: string): boolean {
return ['production', 'preview', 'canary'].includes(env);
}
OG image generation
Every link page and shortened URL has an OG image generated at request time. We use workers-og, a port of Vercel's OG image library, which runs in the Workers V8 environment with Inter font embedded as a base64 data URL.
The OG image for a link page:
import { ImageResponse } from 'workers-og';
export async function generateLinkPageOgImage(page: LinkPage): Promise<Response> {
return new ImageResponse(
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', background: page.backgroundColor || '#fff', padding: '40px' }}>
<h1 style={{ fontSize: '48px', fontWeight: 700, color: page.textColor || '#000' }}>
{page.title}
</h1>
<p style={{ fontSize: '24px', color: page.textColor || '#555' }}>
{page.bio}
</p>
</div>,
{ width: 1200, height: 630, fonts: [{ name: 'Inter', data: interFontData }] }
);
}
Generated OG images are cached in KV with a 24-hour TTL, keyed by the page's slug and a hash of its content. On cache hit, we return the cached image directly without running the JSX-to-PNG pipeline.
Cron jobs
We run two scheduled handlers:
0 * * * * — hourly rollup. The WAE-to-D1 rollup described above. Runs only in production-like environments.
*/5 * * * * — homepage counter. Aggregates the total click and view counts from urls.click_count, link_pages.view_count, and link_items.click_count and writes a { total, ratePerSec, updatedAt } object to KV under stats:global:total_clicks. The homepage displays this counter in real time. In local dev, the counter falls back to a live DB query when KV is empty.
Cron handlers run inside the Worker but outside the request lifecycle:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
return app.fetch(request, env, ctx);
},
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(handleScheduled(event, env));
},
};
Security layers
A few security decisions worth noting:
Content Security Policy. The CSP is set server-side in a middleware function. frame-src allows YouTube, Vimeo, and Spotify for embedded media. Permissions-Policy allows gyroscope and accelerometer for those origins (required for their embed players) — these must be double-quoted per spec: "https://player.vimeo.com" not bare.
Upload validation. Every file upload validates magic bytes, not just MIME type or extension. A .jpg file with a PE executable header is rejected regardless of what the Content-Type says. This runs unconditionally — it is not tiered by premium status.
Idempotency. State-changing API calls from the frontend include an idempotency key (a UUID generated on the client for the request). The Worker stores (idempotency_key, status, response_body) in D1 and returns the cached response on duplicate requests. This prevents double-submits from network retries.
What is not in the stack
For completeness: things we evaluated and did not use.
Durable Objects. We did not need per-entity coordination or WebSocket state, so Durable Objects were not a fit. KV handles our caching and rate-limiting needs adequately.
Cloudflare Queues. All our async work (analytics writes, OG image generation) is either non-blocking (WAE writeDataPoint is fire-and-forget) or handled by the hourly cron. We did not hit a use case that required a persistent queue.
Vercel. We evaluated a Worker-for-API, Vercel-for-frontend split briefly. Serving the SPA from the same Worker as the API simplified deployment, eliminated a separate cold-start budget, and kept the short link redirect latency entirely on Cloudflare's network.
Traditional managed database. D1's SQLite model was sufficient for our schema and query patterns. The main limitation is D1's write throughput (a few hundred writes per second per database), which is relevant for the analytics hot path — hence the WAE write path in production, with D1 only receiving the aggregated hourly rollup rather than per-click writes.
Deployment
The full build and deploy pipeline is a single command:
npm run deploy
Which runs:
vite build && vite build --ssr && wrangler deploy
Vite produces the client bundle and the SSR bundle. wrangler deploy uploads both, uploads source maps, and updates the Worker. No separate CDN invalidation, no S3 sync, no container image build. The deploy takes about 30 seconds.
Database migrations are a separate step — intentionally not automatic:
npm run db:migrate:remote
This runs wrangler d1 migrations apply with the remote D1 binding. Keeping migrations manual means a deploy cannot accidentally run a breaking migration against the live database.
Things we would do differently
WAE query API rate limits. The hourly rollup queries the WAE SQL API via a REST endpoint. At high event volume, WAE's auto-sampling becomes aggressive. We handle this with SUM(_sample_interval) extrapolation, but the estimates become less precise. A future option is to write a parallel D1 direct path for high-volume entities and use WAE purely for the long tail.
D1 write throughput. Denormalized counters (urls.click_count, etc.) are incremented on the redirect hot path with a UPDATE ... SET click_count = click_count + 1. Under concurrent load, D1's SQLite locking means this serialises. At very high throughput, we would move the hot counter to a KV increment and reconcile to D1 periodically.
Testing against real bindings. Vitest runs against mock D1/KV/R2 implementations. The mocks are good but not identical to production bindings — particularly around D1's batch limits and KV's consistency model. We run integration smoke tests against a dedicated staging Worker before every deploy to catch binding-specific bugs.
The numbers
Reproducible 1URL build evidence
The repository checks crawler-visible HTML for every static public route and keeps the homepage on the Worker metadata path with selective Worker-first asset routing. Short-link redirects remain separate 302 responses, while public HTML contains a canonical URL, one visible H1, internal links, and parseable JSON-LD before JavaScript runs.
The production build also rejects any individual SSR artifact above 900 KiB. On 24 August 2026, the measured SSR output was 28 KiB for public link pages, 96 KiB for programmatic pages, and 376 KiB for the largest shared SSR chunk. Run npm run check to reproduce the build and artifact gates from the current source.
Analytics storage is bounded in code: raw IP addresses and user-agent strings are used only as inputs to a daily salted hash and are not written to the reporting table. The stored rollup contains the owner, entity type, entity ID, day, country, device class, and aggregate counts. Referrer reporting is not available today.
Cloudflare Workers' free tier (100,000 requests per day, 10ms CPU per request) is sufficient for personal projects and small teams. The paid plan ($5/month for 10 million requests) covers most production workloads. D1's free tier (5GB storage, 5 million reads/day) is generous. R2's free tier (10GB storage, 1 million writes/month) is sufficient for user assets at modest scale.
The entire infrastructure for 1URL.at at early-production scale costs less per month than a single t3.small EC2 instance — with lower latency to most users, no ops overhead, and automatic global distribution.
Further reading
- Cookieless analytics: a practical guide — the analytics architecture in more depth.
- Short link with analytics in under 60 seconds — using the product from the end-user perspective.
- 1URL.at features — what is available on each plan.
- Privacy policy — the current data-handling facts and legal references.
- Cloudflare static asset routing — selective Worker-first routing used for the homepage.
- Hono documentation — the routing framework.
- Drizzle ORM documentation — the ORM we use for D1.
- Workers Analytics Engine documentation — Cloudflare's time-series analytics primitive.