Deploy in one click
This provisions the Worker from the Cloudflare dashboard. It is not yet a complete install on its own: you still need to set APP_URL, APP_HOST and the secrets, apply the migrations, and attach a domain. Follow Deploy to Cloudflare Workers (tracked in #280).
Features
- Short links with custom or random slugs, optional UTM parameters (source, medium, campaign, term, content) applied at redirect time.
- Grouped link addresses: renaming a link keeps the old slug redirecting for 48 hours before it's freed, and a link can carry extra aliases that all redirect to the same destination.
- QR codes per link (paid plans), with an optional logo in the center, live preview, and PNG/SVG download.
- Custom domains (Pro): connect
links.yourbrand.comvia Cloudflare for SaaS, with slugs namespaced per domain. - Organizations with
owner/admin/memberroles: admins manage the team, domains, and billing; members manage links. - Magic-link invites: single-use, valid for 7 days, sent by email.
- Privacy-friendly analytics: clicks per day, top links, countries, referrers, devices. No IP addresses are ever stored.
- Per-user Free/Hobby/Pro billing through Polar, which acts as Merchant of Record and handles tax/VAT.
- Secret-pinned superadmin console: the account matching
SUPERADMIN_EMAILgets a cloud-console panel for usage, org drill-down, and plan management. It 404s for everyone else.
Stack
rdyrct is built Cloudflare-first: the entire product runs in a single Worker with no separate backend to host:
- Cloudflare Workers: one Worker serves the API, the redirect hot path, and the static React app.
- D1: SQLite at the edge, source of truth for auth, orgs, links, clicks, and domains.
- KV: slug → destination lookups on the redirect hot path, so a click never waits on D1.
- R2: QR logo images, keyed by org, served immutable and cached.
- Cloudflare for SaaS: custom hostnames for Pro orgs' own short-link domains.
- Workers Rate Limiting: separate abuse controls for auth, email, writes, uploads, domains, billing, and click recording.
Application layer:
- Hono for routing
- BetterAuth for email+password auth, with email-OTP verification and PBKDF2/WebCrypto hashing
- Drizzle ORM over D1
- React 19 + Vite +
@cloudflare/vite-plugin - Tailwind v4 + Base UI
- TanStack Query + React Router
- Polar for billing, Resend for transactional email
Local development
bun install
cp .dev.vars.example .dev.vars # then edit; at minimum set SUPERADMIN_EMAIL
bunx emulate --service resend # local Resend inbox on :4000
bun run db:migrate:local
bun run dev # https://rdyrct.localhost
Sign up with the address you set as
SUPERADMIN_EMAILto unlock the admin console; any other address is a normal user.New accounts see a create-org prompt on org-scoped pages until they create their first organization.
Email verification codes are sent through emulate.dev's Resend emulator instead of a real inbox. Read it with:
curl localhost:4000/emails -H 'authorization: Bearer test_token_admin'and copy the 6-digit code out of the latest message.
Setting
CF_DEV_ENV=simulated(the.dev.vars.exampledefault) stubs the Cloudflare Custom Hostnames API locally: DNS becomes ready after about 3 seconds and TLS after about 12 seconds, so "Check status" shows the same progression without a real zone.CF_DEV_ENV=instantreports both ready at once, which is what the e2e run uses so it does not sit through the timer. Without either, missingCF_API_TOKEN/CF_ZONE_IDfails closed instead of faking a response.Billing against a real Polar sandbox account needs a public URL (
wrangler dev --remoteor a tunnel) for webhooks to reach/api/webhooks/polar.
Tests
bun run test # fast unit tests
bun run test:worker # Worker routes with local D1 and KV bindings
bun run e2e:install # install Chromium for Playwright once
bun run e2e:smoke # real-browser baseline regression checks
bun run verify # types, lint, formatting, unit, and Worker tests
Playwright owns the local server lifecycle and never resets or seeds localCloudflare state. Failed browser tests keep a screenshot and trace intest-results/.
Deploy to Cloudflare Workers
Minimum to run
- A domain on Cloudflare (or the free
*.workers.devaddress, to try it first). - Two secrets:
BETTER_AUTH_SECRET(any long random string) andSUPERADMIN_EMAIL(the account that becomes the platform admin). - The resources below, then
bun run db:migrate:remote && bun run deploy.
Everything else is optional. Each piece says what it adds:
| Setting | Adds |
|---|---|
RESEND_API_KEY, MAIL_FROM |
Email: sign-up verification codes, password reset, invites, notices. Without it, sign-up skips verification (see below). |
POLAR_* |
Selling Hobby and Pro plans. Without it, nobody is capped and billing is hidden. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
"Continue with Google". |
CF_API_TOKEN, CF_ZONE_ID |
Customers' own domains through Cloudflare for SaaS. |
SHARED_LINK_HOST |
A second, shorter host for new shared-domain links. |
CAP_SECRET |
Proof-of-work in front of sign-up and password reset. |
SENTRY_DSN |
Error reports to your Sentry project. |
With no email provider and no Google sign-in, accounts are not verified and passwords cannot be reset. That is fine for a private instance and not recommended for a public one. The admin console says so while it is true.
Create the resources
bunx wrangler kv namespace create LINKS
bunx wrangler d1 create rdyrct
bunx wrangler r2 bucket create rdyrct-media
bunx wrangler queues create rdyrct-storage
bunx wrangler queues create rdyrct-storage-dlq
Paste the returned ids into wrangler.jsonc:
kv_namespaces[0].idd1_databases[0].database_id
The R2 bucket name in wrangler.jsonc is rdyrct-qr-logos. Either create that name, or change r2_buckets[0].bucket_name to the one you created.
Set the variables and secrets
In wrangler.jsonc, set APP_URL (for example https://links.example.com, no trailing slash) and APP_HOST (links.example.com). The file ships with rdyrct.com's values for the rest (MAIL_FROM, POLAR_*, CF_ZONE_ID, SENTRY_DSN): clear or replace each one you do not use.
Set the secrets. Either one by one:
bunx wrangler secret put BETTER_AUTH_SECRET
bunx wrangler secret put SUPERADMIN_EMAIL
or in bulk with cp prod.secrets.env.example prod.secrets.env, fill it in, then:
bunx wrangler secret bulk prod.secrets.env && rm prod.secrets.env
Then migrate and ship:
bun run db:migrate:remote
bun run deploy
Attach your domain
Cloudflare dashboard → Workers → your worker → Settings → Domains & Routes → Add → Custom domain. Short links live at the root (https://links.example.com/<slug>); the app is served on every other path. Keep routes out of wrangler.jsonc: a routes list replaces whatever is attached in the dashboard, and committing yours would hand it to every fork. The */* route is only for customers' own domains (below).
Email (optional)
Set RESEND_API_KEY and MAIL_FROM (a sender at a domain you verified in Resend). Without them no mail goes out: sign-up skips the verification code and signs the user in, and the password-reset form says it needs an email provider. Sending lives in one function, sendEmail() in src/worker/email.ts, so another provider is a change there.
Selling plans (optional)
Skip this section to run with no caps and no billing. To sell plans, set POLAR_SERVER=sandbox (or production when live), POLAR_PRO_PRODUCT_ID and POLAR_HOBBY_PRODUCT_ID (recurring products you create in Polar), and the secrets POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET.
Polar setup: create an Organization Access Token with scopes checkouts:write and customer_sessions:write. Add a webhook endpoint at https://rdyrct.com/api/webhooks/polar and subscribe to all five events the Worker handles. Miss one and billing state drifts:
| Event | What it does |
|---|---|
subscription.active |
Starts the subscription: plan, customer, price, and access |
subscription.updated |
The full subscription snapshot: status, product, cancellation state, period end |
subscription.canceled |
Marks it as ending at the period end; access continues until then |
subscription.uncanceled |
Clears a scheduled cancellation |
subscription.revoked |
Ends it: the subscription and its access go away |
Which subscription statuses keep access is one decision, in src/worker/entitlement.ts. active, trialing and past_due keep it, because Polar retries a failed payment for days and sends subscription.revoked when it gives up. unpaid, canceled, incomplete and incomplete_expired do not, and a status with no rule keeps nothing.
Paid access an admin grants by hand is a comp, stored apart from the subscription and never counted as a subscriber. A comp outranks the subscription underneath it, so revoking a subscription cannot take away access an admin gave.
The admin console reports counts, never money: how many people hold a paid plan, how many pay for it, and how many are comped. Who is cancelling shows on their own row in the user list, where it names someone. Revenue is read in the Polar dashboard, which knows what it charged net of discounts, tax and refunds.
Second shared-domain host (optional). Attach a shorter domain the same way, check that https://<it>/ answers with a redirect, and only then turn it on with bunx wrangler secret put SHARED_LINK_HOST. It takes effect without a deploy, and bunx wrangler secret delete SHARED_LINK_HOST turns it off. In that order because every new link starts using it at once: set before the domain resolves, it hands out links that go nowhere. Old links keep working on both hosts, since a shared-domain slug's KV key has no host in it. Unset, shared links use APP_HOST.
Review the rate-limiting policies, monitoring, WAF rule, and rollback stepsbefore the first production deploy.
Review the architecture, data flows, and improvement prioritiesbefore changing link, domain, logo, or organization delete flows.
Customer custom domains (Pro): to let orgs use links.theirbrand.com, enable Cloudflare for SaaS on the rdyrct.com zone, create an originless proxied fallback origin (e.g. fallback.rdyrct.com AAAA 100::), and add a Worker route */* pointing at the rdyrct worker. The CF_API_TOKEN needs permission Zone → SSL and Certificates → Edit scoped to the zone. Also verify your sending domain (e.g. mail.rdyrct.com) in Resend so transactional email isn't blocked.
Configuration
| Name | Kind | Purpose |
|---|---|---|
BETTER_AUTH_SECRET |
secret | Signs sessions and tokens |
SUPERADMIN_EMAIL |
secret | The account that becomes the platform admin |
RESEND_API_KEY |
secret | Resend API key for transactional email |
POLAR_ACCESS_TOKEN |
secret | Polar API access token (scopes: checkouts:write, customer_sessions:write) |
POLAR_WEBHOOK_SECRET |
secret | Verifies Polar webhook signatures; endpoint https://rdyrct.com/api/webhooks/polar |
CF_API_TOKEN |
secret | Cloudflare token with Zone → SSL and Certificates → Edit (custom domains) |
APP_URL |
var | Full public URL of the app, e.g. https://rdyrct.com |
APP_HOST |
var | Public host, e.g. rdyrct.com |
SHARED_LINK_HOST |
secret (opt.) | Second shared-domain host for new links, e.g. rdyr.cc; set after it resolves |
MAIL_FROM |
var | From address for outgoing email |
RESEND_BASE_URL |
var (dev only) | Points at the local Resend emulator |
POLAR_SERVER |
var | sandbox or production |
POLAR_PRO_PRODUCT_ID |
var | Polar product id for the Pro plan |
POLAR_HOBBY_PRODUCT_ID |
var | Polar product id for the Hobby plan |
CF_ZONE_ID |
var | Zone id used for Custom Hostnames |
CF_DEV_ENV |
var (dev only) | simulated fakes the Custom Hostnames API on a timer, instant without one |
Secrets are set with wrangler secret put NAME or wrangler secret bulk prod.secrets.env in production; locally they all come from .dev.vars (see .dev.vars.example).
Project layout
migrations/ D1 schema (numbered SQL migrations, applied in order)
scripts/ Local dev utilities (e.g. seed-local.ts)
src/worker/ Hono API, BetterAuth, KV publishing, redirect hot path
routes/ auth/user, orgs, links, qr-logos, domains, billing, admin
better-auth.ts plan.ts util.ts guards.ts org-role.ts rate-limit.ts session.ts
email.ts password.ts kv.ts storage.ts alerts.ts clicks.ts workflows.ts
src/shared/types.ts DTOs + PLAN_LIMITS (shared worker ↔ app)
src/app/ React SPA
routes/ page-level route components
ui/ design-system primitives
components/ feature components
lib/ client utilities, API hooks
.agents/skills/ Agent skills (react-doctor)
.claude/skills/ Claude skills (fallow, react-doctor)
License
MIT © Andrea Bruno. See LICENSE.