Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The documented Relay deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.
Hosting requirements
- Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
- Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
- Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
Sources checked 01/10/2026
Repository snapshot: 7fb2f44. Hosting eligibility reflects the deployment documentation and listed assumptions.
- bitly ↗
A link shortener you fully own, running on the Cloudflare edge. A **single Worker** is both the redirect engine and the admin API, data lives in **D1**, and the admin dashboard is a **single `index.html`** (zero build, zero external dependencies).
- rebrandly ↗
A link shortener you fully own, running on the Cloudflare edge. A **single Worker** is both the redirect engine and the admin API, data lives in **D1**, and the admin dashboard is a **single `index.html`** (zero build, zero external dependencies).
- short-io ↗
A link shortener you fully own, running on the Cloudflare edge. A **single Worker** is both the redirect engine and the admin API, data lives in **D1**, and the admin dashboard is a **single `index.html`** (zero build, zero external dependencies).
- workers ↗
name = "relay" main = "worker.js" compatibility_date = "2024-09-23" # ── D1 資料庫綁定 ────────────────────────────────────────────── # 用「Deploy to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數
- d1 ↗
y to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數 ───────────────────────────────────────────────── [vars] # 後台網域;留 "*" 放行所有來源(API 仍需 Bearer token)。上線後建議改成你的後台網址。 DASH_ORIGIN = "*" # 後台統計時區偏移(小時)。台灣 +8;其他地區改成你的時區(例:美西 -8、UTC 0)。 TZ_OFFSET = "8" # 目的地網域黑名單(逗號分隔,含子網域)。留空=不啟用。例:"bit.ly,grabify.link" BLOCKLIST = "" # 尊重 Do-N
- free-tier-eligible ↗
name = "relay" main = "worker.js" compatibility_date = "2024-09-23" # ── D1 資料庫綁定 ────────────────────────────────────────────── # 用「Deploy to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數
- free-tier-eligible ↗
y to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數 ───────────────────────────────────────────────── [vars] # 後台網域;留 "*" 放行所有來源(API 仍需 Bearer token)。上線後建議改成你的後台網址。 DASH_ORIGIN = "*" # 後台統計時區偏移(小時)。台灣 +8;其他地區改成你的時區(例:美西 -8、UTC 0)。 TZ_OFFSET = "8" # 目的地網域黑名單(逗號分隔,含子網域)。留空=不啟用。例:"bit.ly,grabify.link" BLOCKLIST = "" # 尊重 Do-N
- free-tier-eligible ↗
up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co
- free-tier-eligible ↗
oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie
- MIT ↗
MIT License Copyright (c) 2026 Relay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMEN
- architecture ↗
name = "relay" main = "worker.js" compatibility_date = "2024-09-23" # ── D1 資料庫綁定 ────────────────────────────────────────────── # 用「Deploy to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數
- architecture ↗
y to Cloudflare」按鈕或 `wrangler deploy` 部署時,沒填 database_id 會自動建立一個。 # 手動 CLI:先 `wrangler d1 create relay`,把回傳的 id 解開下面那行貼上。 [[d1_databases]] binding = "DB" database_name = "relay" # database_id = "<your-d1-database-id>" # ── 非機密變數 ───────────────────────────────────────────────── [vars] # 後台網域;留 "*" 放行所有來源(API 仍需 Bearer token)。上線後建議改成你的後台網址。 DASH_ORIGIN = "*" # 後台統計時區偏移(小時)。台灣 +8;其他地區改成你的時區(例:美西 -8、UTC 0)。 TZ_OFFSET = "8" # 目的地網域黑名單(逗號分隔,含子網域)。留空=不啟用。例:"bit.ly,grabify.link" BLOCKLIST = "" # 尊重 Do-N
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Short-link management, redirect routing and click reporting; complete commercial integrations, enterprise controls and managed operations are excluded.
See supporting source ↗Short-link management, redirect routing and click reporting; complete commercial integrations, enterprise controls and managed operations are excluded.
See supporting source ↗Short-link management, redirect routing and click reporting; complete commercial integrations, enterprise controls and managed operations are excluded.
See supporting source ↗How it works
The shape of Relay on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit 7fb2f441e19e. Files were read as data; upstream applications and CI jobs were not executed.
Deployment configuration · 1 files
Cloudflare Workers · compatibility 2024-09-23
relay · default
Entrypoint: worker.js
DB→ D1
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L17 · fetch handler exported · calls filter, url.pathname.split, handleApi, parts.slice, handleTrack, handleRedirect
- L50 · scheduled handler exported · calls ctx.waitUntil, pruneOld
- L57 · handleRedirect calls (conditional paths may differ): RESERVED.has, notFound, lookupLink, Date.parse, Date.now, htmlResponse, pageExpired, url.searchParams.get, sha256, url.searchParams.has, pagePassword, request.headers.get, parseUA, pickTarget, applyUtm, isBot, optedOut, ctx.waitUntil, recordClick, pageInterstitial
- L108 · pickTarget calls (conditional paths may differ): safeParse, variants.filter, valid.reduce, Number, Math.random, toUpperCase, String, Array.isArray
- L142 · applyUtm calls (conditional paths may differ): safeParse, Object.entries, u.searchParams.set, u.toString
- L160 · recordClick calls (conditional paths may differ): now.toISOString, localDay, now.getTime, request.headers.get, refHost, slice, sha256, run, bind, env.DB.prepare, suffix.slice, localHour, referrer.slice, console.error
- L194 · handleTrack calls (conditional paths may differ): request.headers.get, Object.fromEntries, ct.includes, request.json, request.formData, normSlug, lookupLink, run, bind, env.DB.prepare, slice, String, Number, now.toISOString, localDay, now.getTime, console.error
- L242 · convStats calls (conditional paths may differ): first, bind, env.DB.prepare, all
- L262 · handleApi calls (conditional paths may differ): corsHeaders, request.headers.get, auth.replace, json, apiOverview, apiList, apiCreate, request.json, Number, apiGet, apiUpdate, apiDelete, url.searchParams.get, apiStats, apiExport, String
- L311 · apiOverview calls (conditional paths may differ): first, env.DB.prepare, localDay, Date.now, bind, uniqueCount, all, daysAgo
- L345 · apiList calls (conditional paths may differ): all, env.DB.prepare, map
- L354 · apiGet calls (conditional paths may differ): first, bind, env.DB.prepare, shapeLink
- L360 · apiCreate calls (conditional paths may differ): normSlug, first, bind, env.DB.prepare, toISOString, normFields, run
- L381 · apiUpdate calls (conditional paths may differ): first, bind, env.DB.prepare, normSlug, normFields, toISOString, run, invalidate
- L414 · apiDelete calls (conditional paths may differ): first, bind, env.DB.prepare, run, invalidate
- L423 · apiStats calls (conditional paths may differ): first, bind, env.DB.prepare, daysAgo, all, Promise.all, grp, uniqueCount, convStats, shapeLink
- L475 · normFields calls (conditional paths may differ): includes, String, Array.isArray, safeParse, map, arr.filter, Number, valid.forEach, assertHttp, JSON.stringify, filter, slice, toUpperCase, rules.forEach, hostOf, blocklistedHost, safeBrowsingBad, sha256, normSlug, pick
- L553 · shapeLink calls (conditional paths may differ): safeParse
- L579 · pageInterstitial calls (conditional paths may differ): JSON.stringify, escapeAttr, encodeURIComponent
- L603 · pagePassword calls (conditional paths may differ): escapeAttr
- L625 · parseUA calls (conditional paths may differ): ua.toLowerCase, test
- L642 · isBot calls (conditional paths may differ): test
- L653 · optedOut calls (conditional paths may differ): request.headers.get
- L658 · sha256 calls (conditional paths may differ): crypto.subtle.digest, encode, join, map, padStart, b.toString
- L663 · normSlug calls (conditional paths may differ): replace, trim, String
- L670 · safeParse calls (conditional paths may differ): JSON.parse
- L675 · pick calls (conditional paths may differ): String
- L681 · tzOffsetHours calls (conditional paths may differ): Number, Number.isFinite
- L685 · localDay calls (conditional paths may differ): slice, toISOString, tzOffsetHours
- L688 · localHour calls (conditional paths may differ): getUTCHours, tzOffsetHours
- L691 · daysAgo calls (conditional paths may differ): localDay, Date.now
- L696 · assertHttp calls (conditional paths may differ): String
- L705 · hostOf calls (conditional paths may differ): hostname.toLowerCase, String
- L710 · blocklistedHost calls (conditional paths may differ): String, filter, map, raw.split, toLowerCase, s.trim, list.some, host.endsWith
- L718 · safeBrowsingBad calls (conditional paths may differ): fetch, encodeURIComponent, JSON.stringify, urls.map, res.json, bad.add
- L748 · lookupLink calls (conditional paths may differ): env.LINKS_KV.get, first, bind, env.DB.prepare, Math.max, Number, env.LINKS_KV.put, JSON.stringify
- L763 · invalidate calls (conditional paths may differ): env.LINKS_KV.delete
- L770 · uniqueCount calls (conditional paths may differ): first, bind, env.DB.prepare
- L780 · apiExport calls (conditional paths may differ): url.searchParams.get, Number, conds.push, binds.push, Number.isFinite, daysAgo, conds.join, all, bind, env.DB.prepare, cols.join, toLowerCase, JSON.stringify, String, test, s.replace, lines.push, join, cols.map, esc
- L808 · pruneOld calls (conditional paths may differ): Number, Number.isFinite, daysAgo, run, bind, env.DB.prepare, console.error
Environment references: env.SALT · env.ADMIN_TOKEN · env.DB · env.CONVERSION_TOKEN · env.RESPECT_DNT · env.TZ_OFFSET · env.BLOCKLIST · env.SAFEBROWSING_KEY · env.LINKS_KV · env.CACHE_TTL · env.RETENTION_DAYS · env.DASH_ORIGIN
Build and deployment pipeline · 1 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: push, pull_request
test · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
node --test
Repository README
View original on GitHub ↗Full upstream document by @YuriCrystal · README.md · snapshot 7fb2f44
Relay · self-hosted link shortener
English · 繁體中文
A link shortener you fully own, running on the Cloudflare edge.
A single Worker is both the redirect engine and the admin API, data lives in D1, and the admin dashboard is a single index.html (zero build, zero external dependencies).
Just open index.html in a browser to try it — it defaults to DEMO mode (fake data; create / edit / view analytics). Follow the steps below to go live for real in about 10 minutes.
Core idea: a short link isn't just "making a URL shorter" — it's a relay station you control. Every link passes through your station before redirecting out, so you can track performance, change destinations any time, and build a retargeting audience.
Why Relay?
Link shorteners are everywhere — so why self-host one? Because with a free link shortener, you're really paying with your own traffic data — and the features you actually want are usually locked behind a paywall. Relay puts that relay station back in your own hands:
Change the destination without changing the link Campaign rotates, landing page moves, a partner drops out — repoint the link from the dashboard any time. Links you've already posted or printed never need to change.
See which channel actually drives clicks
Add a suffix to the same link to split sources: /spring/ig, /spring/threads, /spring/edm each count separately. Which platform, which creator works — read it off the numbers instead of guessing.
Know which channel actually converts
Clicks are only half the story. Report signups / sales back with a cookieless /track postback and the dashboard shows conversions and conversion rate per /suffix — so you see which channel drives results, not just traffic. No cookies, no cross-site identity: the thing cookie-based tools can't do privacy-first.
Turn clickers into your retargeting audience Links can carry FB Pixel / GA4 / GTM — visitors are written into your retargeting list before they even reach the destination. Every click becomes an audience touchpoint instead of leaking away.
A/B test, and route by device or country
Weighted split to see which variant converts; or send traffic to different destinations by device (iOS / Android) and by country (request.cf.country) — one link, the right landing page per audience.
Your data, 100% yours Links, clicks, and audience all go into your own Cloudflare D1. No third party reading your traffic, nobody moving features behind a paywall, no links dying because a service shut down.
Free, no limits, your domain
Runs on your own Cloudflare free tier (100k redirects/day is plenty), no cap on the number of links, and you can attach your own short domain (go.yourbrand.com).
Who it's for
- Creators, marketers, and small teams running multiple platforms / accounts who want to know where attention and results come from
- People who value data ownership and don't want to be locked into — or priced up by — a SaaS
- Anyone who wants short-link infrastructure they fully own and can modify and extend
Who it's not for (straight talk)
- If you just shorten the occasional URL and don't care about data — a ready-made service like Bitly is less hassle
- If you don't want to touch a terminal at all — Relay needs a few
wranglercommands (~10 min) to go live
Features
- Link redirect — edge redirects, low latency worldwide
- Change destination without changing the link — repoint a posted link any time from the dashboard
- Weighted A/B split — randomly send the same link to different versions by weight
- Device & geo routing — send iOS / Android and per-country (
request.cf) traffic to different destinations /suffixsource tracking — add a suffix (e.g./spring/ig) to count sources separately and see which channel works- Marketing pixel interstitial — FB Pixel / GA4 / GTM: clickers are added to your retargeting list before they reach the site
- Password protection, expiry, 301 / 302
- UTM builder, QR code
- Analytics — device / OS / referrer / country / hour / suffix / A-B variant
- No link cap, custom domain, data 100% in your own hands
- Cookieless conversion tracking — attribute signups/sales to links and channels via a
/trackpostback, zero cookies - Unique visitors — total clicks plus a privacy-preserving daily unique count (IP / UA never stored)
- CSV / JSON export — download your raw click data any time
- Optional edge cache & auto-retention — KV-cached redirects for scale; Cron-pruned old clicks
Files
relay/
├─ worker.js redirect engine + admin API (deploys to Cloudflare Workers)
├─ schema.sql D1 tables
├─ wrangler.toml Worker config (committed; D1 auto-provisions, secrets stay out)
├─ index.html single-file admin (drop on Cloudflare Pages, or open locally)
└─ README.md
Deploy
One-click: click the Deploy to Cloudflare button above — it forks the repo, provisions D1 (and KV if you enable it), and deploys. Then run the two commands below to load the schema and set your ADMIN_TOKEN.
Or step by step (~10 minutes):
0. Prerequisites
npm i -g wrangler
wrangler login
1. Create D1 and import the schema
wrangler d1 create relay
# Paste the returned database_id into wrangler.toml (uncomment the database_id line)
wrangler d1 execute relay --remote --file=./schema.sql # cloud
# wrangler d1 execute relay --local --file=./schema.sql # local testing
2. Set the admin secret
wrangler secret put ADMIN_TOKEN
# Enter a long random string — this is your admin login key
3. Deploy the Worker
wrangler deploy
# You get a URL, e.g. https://relay.<your-subdomain>.workers.dev
4. Connect the dashboard
- Open
index.htmlin a browser (or drop it on Cloudflare Pages). - Go to Settings on the left and fill in:
- Worker API URL: the URL from the previous step
- Admin key: the
ADMIN_TOKENyou set in step 2
- Click Test connection — success means you're done. The key lives only in your browser's localStorage.
Custom short domain (optional)
- Add your domain to Cloudflare (e.g.
relay.to). - Uncomment the
[[routes]]block at the bottom ofwrangler.tomland setpattern. wrangler deploy. Your short links are nowhttps://relay.to/spring.
API (all require Authorization: Bearer <ADMIN_TOKEN>)
| Method | Path | Description |
|---|---|---|
| GET | /api/overview |
summary numbers + 14-day trend + top links |
| GET | /api/links |
link list (with click counts) |
| POST | /api/links |
create a link |
| GET | /api/links/:id |
single link |
| PATCH | /api/links/:id |
update |
| DELETE | /api/links/:id |
delete (along with its click records) |
| GET | /api/stats/:id?days=30 |
trend + device/OS/country/referrer/suffix/variant/hour |
| GET | /api/export?format=csv|json&id=&days= |
export clicks (CSV or JSON; optional id / days filters) |
Public redirect: GET /:slug or GET /:slug/:suffix (suffix tracks the source, e.g. /spring/ig).
Conversion postback (public, no Bearer): POST /track or GET /track?slug=… — report a conversion for a slug / suffix (see Conversion tracking). Optionally gated by a CONVERSION_TOKEN.
Before going live
- Destination URLs: on create/update, only
http(s)is accepted, blocking abusable schemes likejavascript:/data:at the source. - Abuse protection: a destination-domain blocklist is built in — set
BLOCKLIST = "a.com,b.com"inwrangler.tomlto block those domains (and subdomains), zero key needed. For more, setSAFEBROWSING_KEY(wrangler secret put) and links are checked against Google Safe Browsing on creation; leave it unset = disabled, still runs fine. - Timezone: dashboard stats (today's clicks / daily trend / hour heatmap) use
TZ_OFFSETinwrangler.toml, default+8(Taiwan). Change it to your timezone elsewhere.
The QR code is generated locally in the browser by the inlined qrcode-generator (MIT) — it hits no third-party endpoint, so your link targets never leak and you don't depend on an external service staying up.
Security notes
- The admin API requires a Bearer token throughout; the token is stored via
wrangler secret, never committed. - Link passwords are stored hashed as
sha256(slug + ':' + password), never in plaintext. - All D1 queries use bound prepared statements to avoid SQL injection.
- Destination URLs on the interstitial page are safely embedded via
JSON.stringify/ attribute escaping to avoid XSS. - Destinations only allow
http/https, blocking dangerous schemes likejavascript:/data:at the source. - The QR code is generated locally in the browser (inlined qrcode-generator), hitting no external endpoint.
robots.txtdefaults toDisallow: /, so short links aren't indexed by search engines.
Conversion tracking (cookieless)
Relay attributes conversions to links without a single cookie. When someone completes an action on your destination (signup, purchase…), your site reports it back — server-to-server, aggregate, no cross-site identity:
curl -X POST https://<your-relay>/track \
-H 'content-type: application/json' \
-d '{"slug":"spring","suffix":"ig","event":"signup"}'
Fields: slug (required), suffix (the channel/KOL tag), variant, event (e.g. signup/purchase), value (optional number). A GET /track?slug=… with query params also works (for navigator.sendBeacon / pixels).
The dashboard then shows conversions, conversion rate, and a clicks→conversions table per channel — so you see which /suffix actually converts, not just which gets traffic. That's the thing cookie-based tools can't do privacy-first.
Abuse protection (optional): set a CONVERSION_TOKEN secret (wrangler secret put CONVERSION_TOKEN) and send it as the X-Conversion-Token header (or token field) — recommended for server-side postbacks. Unset = open beacon (fine for trusted/internal use).
Content loop — which channel to double down on
tools/rank.mjs turns your click + conversion data into a ranked channel scoreboard, so you know which /suffix to push next — not just which gets traffic, but which actually converts.
# from a deployed Relay (per-link stats):
curl -s "https://<your-relay>/api/stats/1?days=30" -H "Authorization: Bearer $ADMIN_TOKEN" \
| node tools/rank.mjs -
# or from an exported file / a hand-built {channels:[...]} JSON:
node tools/rank.mjs data.json
It ranks channels by a Wilson lower bound on conversion rate (so a lucky "1 click, 1 conversion = 100%" never beats a proven channel), flags high-traffic-but-low-conversion sources, and tells you where to lean in. Zero dependencies. See tools/sample.stats.json for the input shape.
Then tools/pick-formula.mjs recommends which social-post formula to write the next post in to maximize reach/CTR — ranked by real break-out-of-follower-bubble evidence, optionally chained to the scoreboard (--data=rank.json):
node tools/pick-formula.mjs --goal=reach # which formula gets seen by new people
node tools/pick-formula.mjs --no-hype # skip hype-heavy formulas
The 27-formula framework is in Traditional Chinese and is derived from Hao0321/claude-skill-social-post (MIT) — see
tools/CREDITS.md.
Privacy
Relay is built to be privacy-friendly by default — it tracks link clicks, not people:
- No cookies, no tracking script. Clicks are counted server-side at redirect time; nothing runs in the visitor's browser and no cross-site identifier is set.
- No IP stored. Only a coarse country code (from Cloudflare's edge) is kept — never the raw IP address.
- No raw User-Agent stored. Only the derived device / OS / browser is kept; the full UA string (a fingerprinting vector) is discarded.
- Referrer reduced to its domain. Only the source host (e.g.
google.com) is stored — never the full URL with its path or query string. - Bots excluded. Link-preview crawlers (Facebook, Slack, Discord, Telegram, etc.) are still redirected so previews work, but aren't counted and don't fire your pixels — so your numbers are real human clicks.
- Honors opt-out (optional). Set
RESPECT_DNT = "1"and visitors sendingDNT/Sec-GPCare redirected without being recorded. - Your data, your server. Everything lives in your own Cloudflare D1; nobody else can read it.
Marketing pixels (FB / GA4 / GTM) are opt-in per link — only links you attach a pixel to load one, and only for real human visitors.
Scaling & upgrading (optional)
- Edge cache — bind a KV namespace as
LINKS_KV(seewrangler.toml) and redirects read from KV first, cutting D1 reads and latency at scale. Edits still take effect withinCACHE_TTL(default 60s). Not bound = always read D1 (instant, current behavior). - Auto-retention — set
RETENTION_DAYSand enable the[triggers]cron; clicks older than N days are pruned daily. Unset = keep forever.
Upgrading from an earlier version? Run this once against your D1, then re-run
schema.sqlto add the newconversionstable (it usesCREATE TABLE IF NOT EXISTS, so it won't touch existing tables):ALTER TABLE clicks ADD COLUMN visitor_hash TEXT DEFAULT '';Fresh installs already include it via
schema.sql. Without it, click recording fails silently until the column is added.
Cost
Fully self-hosted, running on your own Cloudflare account — there's no central server, and the author doesn't pay for anyone. For most people it's $0:
- Workers free tier: 100k requests/day
- D1 free tier: 5GB storage, millions of row reads per day
- Pages (hosts the
index.htmladmin): free
You only pay past the free tier, and you pay your own Cloudflare bill — nothing to do with the author or other users. Fork it, fill in your own database_id and ADMIN_TOKEN, and it's 100% yours.
Development
Pure node:test unit tests cover the redirect / privacy / parsing helpers — zero dependencies:
node --test
CI runs them on every push and PR (.github/workflows/ci.yml).
License
MIT © 2026
Free to use, modify, distribute, and sell — just keep the copyright notice. Forks, stars, and issues welcome.
Frequently asked about Relay
What is Relay?+
Relay is a self-hosted Bitly/Rebrandly alternative built on the Cloudflare developer platform. Manage short links and inspect clicks with a Worker API and static admin page.
What does Relay replace?+
Relay is listed as an alternative to Bitly, Rebrandly, Short.io. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does Relay use?+
Relay is built on D1, Workers.
How much does Relay cost to run?+
The documented Relay deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Check current Cloudflare pricing before deploying.
Is Relay open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/YuriCrystal/relay/7fb2f441e19e7ba446bfbd0cd0e3ca0f53edfb45/LICENSE. Source code and contributor credit are available at https://github.com/YuriCrystal/relay.



Discussion · 0
sign in to comment →