Cloudsteading
Admin dashboard with 30-day analytics

SHRTNR

Run a short-link dashboard and click reports behind Cloudflare Access.

SHRTNR is a self-hosted Bitly/Rebrandly alternative built on Cloudflare (D1, Durable Objects, KV, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: Apache-2.0

License TL;DR

You can use, change and sell it, including in closed-source products. When sharing copies, include the license, keep required notices and mark changed files. It includes a contributor patent grant with conditions, but no trademark permission or warranty.

Explain Apache 2.0 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

@oddbit

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The documented SHRTNR 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.
  • Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes.
  • Use the configured SQLite Durable Object classes within 100,000 requests/day, 13,000 GB-s duration/day, 5 million SQL rows read/day, 100,000 written/day and 5 GB storage; active sockets consume duration.
  • Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
  • Use an eligible Cloudflare Access Free proof-of-concept entitlement and confirm its current user limits; paid Access seats are separate if needed.
Check current pricing ↗
Sources checked 01/10/2026

Repository snapshot: 745fc5c. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • bitly ↗

    shrtnr is a self-hosted URL shortener you drive from code and from AI assistants, not only from a dashboard. Every deployment ships a REST

  • rebrandly ↗

    shrtnr is a self-hosted URL shortener you drive from code and from AI assistants, not only from a dashboard. Every deployment ships a REST

  • short-io ↗

    shrtnr is a self-hosted URL shortener you drive from code and from AI assistants, not only from a dashboard. Every deployment ships a REST

  • workers ↗

    { "name": "shrtnr", "main": "src/index.tsx", "assets": { "directory": "public" }, // Advance periodically after reviewing the flags that turn on between // dates (developers.cloudflare.com/workers/configuration/compatibility-flags) // and running `yarn test` and `yarn e2e`. The local runtime rejects a date // newer than its own release, and wrangler and the vites

  • d1 ↗

    cific and the deploy works without them. "kv_namespaces": [ { "binding": "SLUG_KV" } ], "d1_databases": [ { "binding": "DB", "database_name": "shrtnr-db", "migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ShrtnrMCP"]

  • kv ↗

    nto this file: discard that change, the IDs are account // specific and the deploy works without them. "kv_namespaces": [ { "binding": "SLUG_KV" } ], "d1_databases": [ { "binding": "DB", "database_name": "shrtnr-db", "migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ {

  • durable-objects ↗

    migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ShrtnrMCP"] } ] }

  • free-tier-eligible ↗

    { "name": "shrtnr", "main": "src/index.tsx", "assets": { "directory": "public" }, // Advance periodically after reviewing the flags that turn on between // dates (developers.cloudflare.com/workers/configuration/compatibility-flags) // and running `yarn test` and `yarn e2e`. The local runtime rejects a date // newer than its own release, and wrangler and the vites

  • free-tier-eligible ↗

    cific and the deploy works without them. "kv_namespaces": [ { "binding": "SLUG_KV" } ], "d1_databases": [ { "binding": "DB", "database_name": "shrtnr-db", "migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ShrtnrMCP"]

  • free-tier-eligible ↗

    nto this file: discard that change, the IDs are account // specific and the deploy works without them. "kv_namespaces": [ { "binding": "SLUG_KV" } ], "d1_databases": [ { "binding": "DB", "database_name": "shrtnr-db", "migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ {

  • 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

  • free-tier-eligible ↗

    cing/). | | Free plan<sup>1</sup> | Paid plan | | --- | --- | --- | | Keys read | 100,000 / day | 10 million/month, + $0.50/million | | Keys written | 1,000 / day | 1 million/month, + $5.00/million | | Keys deleted | 1,000 / day | 1 million/month, + $5.00/million | | List requests | 1,000 / day | 1 million/month, + $5.00/million | | Stored data | 1 GB | 1 GB, + $0.50/ GB-month | <sup>1</sup> The Workers Free plan includes limited Workers KV usage. All limits reset daily at 00:00 UTC. If you exceed any one of these limits, further operations of that type will fail with an error. Note Workers KV pricing for read, write and delete operations is on a per-key basis. Bulk read operations are billed by the amount

  • free-tier-eligible ↗

    jects are available both on Workers Free and Workers Paid plans. - **Workers Free plan**: Only Durable Objects with [SQLite storage backend](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/#create-sqlite-backed-durable-object-class) are available. - **Workers Paid plan**: Durable Objects with the SQLite storage backend are available. The [key-value storage backend](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/#storage-backends) is only available to accounts that already have a key-value-backed namespace. If you wish to downgrade from a Workers Paid plan to a Workers Free plan, you must first ensure that you have deleted all Durable Object namespaces with the key-value storage backend. On Workers Free plan: - If you exceed any one of the free tier limits, further operations of that type will fail with an error. - Daily free limits reset at 00:00 UTC. ## Compute billing Durable Objects are billed for compute duration (wall-clock time) while the Durable Object is actively running or is idle in memory but unable to [hibernate](https://developers.cloudflare.com/durable-objects/concepts/durable-object-lifecycle/). Durable Objects that are idle and eligible for hibernation are not billed for duration, even before the runtime has hibernated them. Requests to a D

  • free-tier-eligible ↗

    billed accordingly. | | Free plan | Paid plan | | --- | --- | --- | | Requests | 100,000 / day | 1 million / month, + $0.15/million<br> Includes HTTP requests, RPC sessions<sup>1</sup>, WebSocket messages<sup>2</sup>, and alarm invocations | | Duration<sup>3</sup> | 13,000 GB-s / day | 400,000 GB-s / month, + $12.50/million GB-s<sup>4,5</sup> | <details> <summary> Footnotes </summary> <sup>1</sup> Each <a href="https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/">RPC session</a> is billed as one request to your Durable Object. Every <a href="https://developers.cloudflare.com/durable-objects/best-practices/create-durable-object-stubs-and-send-requests/">RPC method call</a> on a <a href="https://developers.cloudflare.com/durable-objects/">Durable Objects stub</a> is its own RPC session and therefore a single billed request. RPC method calls can return objects (stubs) extending <a href="https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/#lifetimes-memory-and-resource-management"><code>RpcTarget</code></a> and invo

  • free-tier-eligible ↗

    /). | | Workers Free plan | Workers Paid plan | | --- | --- | --- | | Rows reads <sup>1,2</sup> | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written <sup>1,2,3,4</sup> | 100,000 / day | First 50 million / month included + $1.00 / million rows | | SQL Stored data <sup>5</sup> | 5 GB (total) | 5 GB-month, + $0.20/ GB-month | <details> <summary> Footnotes </summary> <sup>1</sup> Rows read and rows written included limits and rates match <a href="https://developers.cloudflare.com/d1/platform/pricing/">D1 pricing</a>, Cloudflare's serverless SQL database. <sup>2</sup> Key-value methods like <code>get()</code>, <code>put()</code>, <code>delete()</code>, or <code>list(

  • free-tier-eligible ↗

    nt-blade-headline lh-1_1 headline-2 mb5 mb5-ns mb5-m mb0-l flex-1 f8">Start a proof of concept with our free plan today.</h2></div><div class="enablement-blade-actions flex flex-wrap ml0 ml0-ns ml0-m ml5-l" style="row-gap:20px;column-gap:20px"><a cla

  • Apache-2.0 ↗

    Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to c

  • architecture ↗

    { "name": "shrtnr", "main": "src/index.tsx", "assets": { "directory": "public" }, // Advance periodically after reviewing the flags that turn on between // dates (developers.cloudflare.com/workers/configuration/compatibility-flags) // and running `yarn test` and `yarn e2e`. The local runtime rejects a date // newer than its own release, and wrangler and the vites

  • architecture ↗

    cific and the deploy works without them. "kv_namespaces": [ { "binding": "SLUG_KV" } ], "d1_databases": [ { "binding": "DB", "database_name": "shrtnr-db", "migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ShrtnrMCP"]

  • architecture ↗

    nto this file: discard that change, the IDs are account // specific and the deploy works without them. "kv_namespaces": [ { "binding": "SLUG_KV" } ], "d1_databases": [ { "binding": "DB", "database_name": "shrtnr-db", "migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ {

  • architecture ↗

    migrations_dir": "migrations" } ], // Durable Object for MCP agent sessions "durable_objects": { "bindings": [ { "name": "MCP_OBJECT", "class_name": "ShrtnrMCP" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["ShrtnrMCP"] } ] }

Upstream screenshot · oddbit/shrtnr repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Bitly logoBitly ↗

Creating short links and custom slugs, bundles and per-link click reports; planned password links, geo routing, import/export, tags and webhooks are excluded.

See supporting source ↗
Rebrandly logoRebrandly ↗

Creating short links and custom slugs, bundles and per-link click reports; planned password links, geo routing, import/export, tags and webhooks are excluded.

See supporting source ↗
Short.io logoShort.io ↗

Creating short links and custom slugs, bundles and per-link click reports; planned password links, geo routing, import/export, tags and webhooks are excluded.

See supporting source ↗
external SaaS target
varies
external SaaS target
varies
external SaaS target
varies

How it works

The shape of SHRTNR 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 ↗
Public interface
Configured entry points1
shrtnr
wrangler.jsonc
↓
App
shrtnr
entry
Cloudflare Workers
Entrypoint: src/index.tsx
↓

Configuration and workflow sources

Reviewed commit 745fc5cd5865. Files were read as data; upstream applications and CI jobs were not executed.

Partial source coverage: 78 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.

Deployment configuration · 1 files
wrangler.jsonc ↗

Cloudflare Workers · compatibility 2026-08-22

shrtnr · default

Entrypoint: src/index.tsx

Static assets: public

  • DB → D1
  • SLUG_KV → KV
  • MCP_OBJECT → Durable Objects · class ShrtnrMCP
  • Static assets → Static assets

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.

src/index.tsx ↗
  • L645 · fetch handler exported · calls SCHEMA_EXEMPT_PATHS.has, ensureSchema, logUnhandledError, isOperatorRequest, schemaErrorResponse, route, unhandledErrorResponse, negotiatedErrorFormat
  • L116 · app.get("/_/health")
  • L117 · app.all("/_/setup")
  • L121 · app.route("/")
  • L125 · app.get("/_/dev/login")
  • L126 · app.get("/_/dev/logout")
  • L133 · app.use("/_/admin/api/*")
  • L134 · app.use("/_/admin/w/*")
  • L135 · app.use("/_/api/*")
  • L139 · app.use("/_/admin/*")
  • L166 · app.use("/_/admin/*")
  • L181 · app.use("/_/admin/api/*")
  • L197 · app.get("/_/admin/logout")
  • L240 · app.get("/_/admin/dashboard")
  • L253 · app.get("/_/admin/links")
  • L309 · app.get("/_/admin/links/:id")
  • L336 · app.get("/_/admin/bundles")
  • L358 · app.get("/_/admin/bundles/:id")
  • L376 · app.get("/_/admin/keys")
  • L389 · app.get("/_/admin/settings")
  • L403 · app.get("/_/admin")
  • L404 · app.get("/_/admin/")
  • L405 · app.get("/_/admin/link/:slug")
  • L406 · app.get("/_/dashboard")
  • L407 · app.get("/_/links")
  • L408 · app.get("/_/links/:id")
  • L409 · app.get("/_/keys")
  • L410 · app.get("/_/settings")
  • L417 · app.get("/_/admin/w/:id")
  • L422 · app.get("/_/admin/api/keys")
  • L423 · app.post("/_/admin/api/keys")
  • L424 · app.delete("/_/admin/api/keys/:id")
  • L431 · app.post("/_/admin/api/links")
  • L432 · app.get("/_/admin/api/links")
  • L433 · app.get("/_/admin/api/links/:id")
  • L438 · app.put("/_/admin/api/links/:id")
  • L443 · app.get("/_/admin/api/links/:id/analytics")
  • L448 · app.get("/_/admin/api/links/:id/timeline")
  • L453 · app.get("/_/admin/api/links/:id/breakdown")
  • L463 · app.post("/_/admin/api/links/:id/disable")

Environment references: c.env.ACCESS_AUD · c.env.ACCESS_JWKS_URL · c.env.MCP_ACCESS_AUD · env.MCP_ACCESS_AUD

src/access.ts ↗
  • L22 · getJwks calls (conditional paths may differ): jwksCaches.get, jwksCaches.set, createRemoteJWKSet
  • L35 · extractToken calls (conditional paths may differ): request.headers.get, cookies.match
  • L69 · audiences calls (conditional paths may differ): filter, map, aud.split, tag.trim
  • L86 · devIdentityFromCookie calls (conditional paths may differ): request.headers.get, cookies.match, trim, decodeURIComponent
  • L102 · parseJwtPayload calls (conditional paths may differ): token.split, JSON.parse, atob
  • L128 · extractIdentity calls (conditional paths may differ): val.trim, extractToken, parseJwtPayload, fromPayload, request.headers.get, emailHeader.trim, devIdentityFromCookie, env.DEV_IDENTITY.trim, getJwks, jwtVerify, audiences
  • L129 · fromPayload calls (conditional paths may differ): val.trim
  • L179 · isSignedIn calls (conditional paths may differ): devIdentityFromCookie, request.headers.has, request.headers.get, cookies.includes, verifyAccessJwt
  • L202 · verifyAccessJwt calls (conditional paths may differ): extractToken, parseJwtPayload, request.headers.get, devIdentityFromCookie, getJwks, jwtVerify, audiences

Environment references: env.DEV_MODE · env.ACCESS_AUD · env.DEV_IDENTITY · env.ACCESS_JWKS_URL

src/access-required.ts ↗
  • L22 · accessNotConfiguredResponse calls (conditional paths may differ): pathname.startsWith, Response.json
src/dev-login.ts ↗
  • L36 · safeReturnPath calls (conditional paths may differ): stripped.startsWith
  • L42 · cookieHeader calls (conditional paths may differ): encodeURIComponent
  • L68 · handleDevLogin calls (conditional paths may differ): isDevMode, url.searchParams.get, loginForm, as.trim, IDENTITY_PATTERN.test, JSON.stringify, escHtml, safeReturnPath, cookieHeader
  • L86 · handleDevLogout calls (conditional paths may differ): isDevMode, cookieHeader
src/redirect.ts ↗
  • L13 · handleRedirect calls (conditional paths may differ): slug.toLowerCase, SlugCache.get, SlugRepository.findForRedirect, notFoundResponse, SlugCache.put, Math.floor, Date.now, request.headers.get, url.searchParams.get, normalizeHost, parseReferrerHost, isSelfReferrer, catch, computeVisitorFingerprint, parseDeviceType, parseOS, parseBrowser, isBot, ctx.waitUntil, recordClick

Environment references: env.SLUG_KV · env.DB · env.FP_SALT

src/auth.ts ↗
  • L6 · unauthorizedResponse calls (conditional paths may differ): JSON.stringify
  • L13 · hasScope calls (conditional paths may differ): includes, auth.scope.split
  • L18 · forbiddenResponse calls (conditional paths may differ): JSON.stringify
src/i18n/index.ts ↗
  • L18 · isSupportedLanguage calls (conditional paths may differ): SUPPORTED_LANGUAGES.includes
  • L22 · getTranslations calls (conditional paths may differ): isSupportedLanguage
  • L27 · createTranslateFn calls (conditional paths may differ): getTranslations, Object.entries, value.replace, String
src/api/health.ts ↗
  • L18 · handleHealth calls (conditional paths may differ): Date.now, lastSchemaFailure, schemaStatus, String, JSON.stringify
src/api/setup.ts ↗
  • L26 · handleSetup calls (conditional paths may differ): checkRateLimit, clientKey, rateLimitedResponse, verifyAccessJwt, unauthorizedResponse, retrySchema, String, catch, schemaStatus, Response.json

Environment references: env.ACCESS_AUD

src/db/migrate.ts ↗
  • L98 · ensureSchema calls (conditional paths may differ): Date.now, Promise.reject, catch, then, migrate
  • L128 · retrySchema calls (conditional paths may differ): resetSchemaGuard, ensureSchema
  • L133 · migrate calls (conditional paths may differ): catch, kv.get, env.DB.batch, env.DB.prepare, listed.results.map, applied.has, migration.statements.map, statements.push, bind, isRecorded, kv.put
  • L181 · isRecorded calls (conditional paths may differ): first, bind, db.prepare
  • L195 · schemaStatus calls (conditional paths may differ): all, env.DB.prepare, isMissingTable, applied.map, filter, MIGRATIONS.map, recorded.has
  • L208 · isMissingTable calls (conditional paths may differ): String, test

Environment references: env.SLUG_KV · env.DB

src/schema-guard.ts ↗
  • L20 · wantsJson calls (conditional paths may differ): JSON_PREFIXES.some, prefix.replace, path.startsWith, includes, request.headers.get
  • L26 · schemaErrorResponse calls (conditional paths may differ): String, wantsJson, Response.json, escHtml
src/assets.ts ↗
  • L42 · contentHash calls (conditional paths may differ): text.charCodeAt, Math.imul, padStart, a.toString, b.toString
  • L87 · adminClientScriptPath calls (conditional paths may differ): scripts.get, isSupportedLanguage
  • L95 · sameAssetFromThisBuild calls (conditional paths may differ): STYLESHEET_PATH.test, path.match, isSupportedLanguage, scripts.get
src/unhandled.ts ↗
  • L32 · logUnhandledError calls (conditional paths may differ): String, console.error, JSON.stringify
  • L47 · negotiatedErrorFormat calls (conditional paths may differ): request.headers.get, accept.includes
  • L53 · unhandledErrorResponse calls (conditional paths may differ): Response.json
src/api/links.ts ↗
  • L526 · handleGetLinkBySlug calls (conditional paths may differ): fromServiceResult, getLinkBySlug, slug.toLowerCase
  • L530 · handleListLinks calls (conditional paths may differ): fromServiceResult, listLinksByOwner, listLinks
  • L535 · handleGetLink calls (conditional paths may differ): fromServiceResult, getLink
  • L539 · handleCreateLink calls (conditional paths may differ): request.json, json, createLink, ctx.waitUntil, autoLabelLink, fromServiceResult
  • L563 · handleUpdateLink calls (conditional paths may differ): request.json, json, fromServiceResult, updateLink
  • L575 · handleDisableLink calls (conditional paths may differ): fromServiceResult, disableLink
  • L579 · handleEnableLink calls (conditional paths may differ): fromServiceResult, enableLink
  • L583 · handleDeleteLink calls (conditional paths may differ): fromServiceResult, deleteLink

Environment references: c.env.DB · env.DB

src/api/slugs.ts ↗
  • L20 · handleAddCustomSlug calls (conditional paths may differ): request.json, json, fromServiceResult, addCustomSlugToLink
  • L36 · handleSetPrimarySlug calls (conditional paths may differ): request.json, json, fromServiceResult, setSlugPrimary
  • L52 · handleDisableSlug calls (conditional paths may differ): fromServiceResult, disableSlug
  • L61 · handleEnableSlug calls (conditional paths may differ): fromServiceResult, enableSlug
  • L70 · handleRemoveSlug calls (conditional paths may differ): fromServiceResult, removeSlug
src/api/settings.ts ↗
  • L11 · handleGetSettings calls (conditional paths may differ): fromServiceResult, getAppSettings
  • L15 · handleUpdateSettings calls (conditional paths may differ): request.json, json, fromServiceResult, updateAppSettings
src/api/keys.ts ↗
  • L12 · handleListKeys calls (conditional paths may differ): fromServiceResult, listAllApiKeys
  • L16 · handleCreateKey calls (conditional paths may differ): request.json, json, fromServiceResult, createNewApiKey
  • L27 · handleDeleteKey calls (conditional paths may differ): fromServiceResult, deleteApiKeyById
src/api/analytics.ts ↗
  • L25 · parseRange calls (conditional paths may differ): VALID_RANGES.has
  • L29 · handleDashboardStats calls (conditional paths may differ): parseRange, fromServiceResult, getDashboardStats
  • L39 · handleAdminLinkAnalytics calls (conditional paths may differ): parseRange, resolveClickFilters, fromServiceResult, getLinkAnalytics
  • L50 · handlePublicLinkAnalytics calls (conditional paths may differ): parseRange, fromServiceResult, getLinkAnalytics
  • L55 · handleAdminLinkTimeline calls (conditional paths may differ): parseRange, resolveClickFilters, fromServiceResult, getLinkTimeline
  • L61 · handlePublicLinkTimeline calls (conditional paths may differ): parseRange, fromServiceResult, getLinkTimeline
  • L68 · handlePublicLinkBreakdown calls (conditional paths may differ): parseRange, fromServiceResult, getLinkBreakdownPage
  • L73 · handleAdminLinkBreakdown calls (conditional paths may differ): parseRange, resolveClickFilters, fromServiceResult, getLinkBreakdownPage
  • L79 · handlePublicBundleBreakdown calls (conditional paths may differ): parseRange, fromServiceResult, getBundleBreakdownPage
  • L84 · handleAdminBundleBreakdown calls (conditional paths may differ): parseRange, resolveClickFilters, fromServiceResult, getBundleBreakdownPage
src/api/bundles.ts ↗
  • L44 · parseRange calls (conditional paths may differ): VALID_RANGES.has
  • L54 · handleListBundles calls (conditional paths may differ): parseArchivedFilter, fromServiceResult, listBundles
  • L59 · handleGetBundle calls (conditional paths may differ): fromServiceResult, getBundle
  • L63 · handleCreateBundle calls (conditional paths may differ): request.json, json, fromServiceResult, createBundle
  • L73 · handleUpdateBundle calls (conditional paths may differ): request.json, json, fromServiceResult, updateBundle
  • L83 · handleArchiveBundle calls (conditional paths may differ): fromServiceResult, archiveBundle
  • L87 · handleUnarchiveBundle calls (conditional paths may differ): fromServiceResult, unarchiveBundle
  • L91 · handleDeleteBundle calls (conditional paths may differ): fromServiceResult, deleteBundle
  • L99 · handleAdminBundleAnalytics calls (conditional paths may differ): parseRange, resolveClickFilters, fromServiceResult, getBundleAnalytics
  • L109 · handlePublicBundleAnalytics calls (conditional paths may differ): parseRange, fromServiceResult, getBundleAnalytics
  • L114 · handleBundleLinks calls (conditional paths may differ): fromServiceResult, listBundleLinks
  • L118 · handleAddLinkToBundle calls (conditional paths may differ): request.json, json, Number, Number.isInteger, fromServiceResult, addLinkToBundle
  • L130 · handleRemoveLinkFromBundle calls (conditional paths may differ): fromServiceResult, removeLinkFromBundle
  • L134 · handleListBundlesForLink calls (conditional paths may differ): fromServiceResult, listBundlesForLink
src/api/qr.ts ↗
  • L11 · handleLinkQr calls (conditional paths may differ): url.searchParams.get, json, Number, Number.isFinite, Number.isInteger, getLink, link.slugs.find, requestedSlug.toLowerCase, pickPrimarySlug, renderQrSvg
Build and deployment pipeline · 8 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.

CI · .github/workflows/ci.yml ↗

Triggers: push, pull_request, workflow_dispatch

Worker (yarn test) · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. Install dependenciesyarn install --frozen-lockfile
  4. Generate Worker typesyarn types
  5. Typecheckyarn tsc --noEmit
  6. Run vitest suiteyarn test --run

Browser e2e (Playwright) · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. Install dependenciesyarn install --frozen-lockfile
  4. Typecheck e2e suiteyarn tsc --noEmit -p e2e
  5. Install Chromiumyarn playwright install --with-deps chromium
  6. Run e2e suiteyarn e2e
  7. Upload Playwright reportactions/upload-artifact@v7Condition: failure()

SDK — TypeScript · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. Install dependenciesyarn install --frozen-lockfile
  4. Testyarn test
  5. Buildyarn build

SDK — Python · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-python@v7actions/setup-python@v7
  3. Install dev dependenciespython -m pip install --upgrade pip pip install -e '.[dev]'
  4. Lint (ruff)ruff check . ruff format --check .
  5. Typecheck (mypy --strict)mypy --strict src/shrtnr
  6. Test (pytest)pytest

SDK — Dart · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. dart-lang/setup-dart@v1dart-lang/setup-dart@v1
  3. Install dependenciesdart pub get
  4. Analyzedart analyze --fatal-infos
  5. Testdart test --exclude-tags e2e

Browser extension · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. Install dependenciesyarn install --frozen-lockfile
  4. Testyarn test
  5. Buildyarn build
  6. Verify build artifactsnode scripts/verify-build.mjs
  7. Lint Firefox build (web-ext)yarn lint:firefox

SDK spec hash parity · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. Install root dependenciesyarn install --frozen-lockfile
  4. Compute current spec hashhash=$(./scripts/spec-hash.sh) echo "hash=$hash" >> "$GITHUB_OUTPUT" echo "Current spec hash: $hash"
  5. Check TypeScript SDK manifest hashrecorded=$(jq -r '."x-spec-hash"' sdk/typescript/package.json | sed 's/^sha256://') if [ "$recorded" != "$CURRENT" ]; then echo "::error file=sdk/typescript/package.json::TypeScript SDK x-spec-hash is stale" echo " recorded: sha256:$recorded" echo " current: sha256:$CURRENT" echo "Resolve by regenerating the SDK against the current spec and bumping the hash, or by updating the hash if the spec change does not affect the SDK surface (see CLAUDE.md)." exit 1 fi
  6. Check Python SDK manifest hash# Parse spec_hash from [tool.shrtnr] only. Bare grep would match any table; awk # tracks the current section header so it stops at the correct key. recorded=$(awk ' /^\[/ { current_table = $0 } current_table == "[tool.shrtnr]" && /^spec_hash *=/ { gsub(/.*= *"/, ""); gsub(/".*/, ""); print exit } ' sdk/python/pyproject.toml | sed 's/^sha256://') if [ "$recorded" != "$CURRENT" ]; then echo "::error file=sdk/python/pyproject.toml::Python SDK [tool.shrtnr] spec_hash is stale" echo " recorded: sha2…
  7. Check Dart SDK manifest hash# Hash is stored as a leading comment to avoid a pana score deduction for unknown top-level keys. recorded=$(grep '^# x-spec-hash:' sdk/dart/pubspec.yaml | awk '{print $3}' | sed 's/^sha256://') if [ "$recorded" != "$CURRENT" ]; then echo "::error file=sdk/dart/pubspec.yaml::Dart SDK x-spec-hash is stale" echo " recorded: sha256:$recorded" echo " current: sha256:$CURRENT" echo "Resolve by regenerating the SDK against the current spec and bumping the hash, or by updating the hash if the spec cha…
Migrate · .github/workflows/migrate.yml ↗

Triggers: check_suite

migrate · no job dependencies declared

Condition: github.event.check_suite.conclusion == 'success' && github.event.check_suite.head_branch == 'main' && github.event.check_suite.app.slug != 'github-actions'

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. Install dependenciesyarn install --frozen-lockfile
  4. Apply database migrationsyarn db:migrate:remote
Release browser extension · .github/workflows/release-extension.yml ↗

Triggers: push, workflow_dispatch

release · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. Resolve versionCURRENT=$(bash scripts/read-version.sh browser-extensions/package.json) TAG="ext-v$CURRENT" BEFORE="${{ github.event.before }}" AFTER="${{ github.sha }}" MANIFEST_CHANGED="unknown" if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then if git diff --name-only "$BEFORE" "$AFTER" -- browser-extensions/package.json | grep -Fxq browser-extensions/package.json; then MANIFEST_CHANGED="true" else MANIFEST_CHANGED="false" fi fi if [ "$MANIFEST_CHANGED" = "false" ]; the…
  3. actions/setup-node@v7actions/setup-node@v7Condition: steps.version.outputs.should_run == 'true'
  4. Install + test + buildyarn install --frozen-lockfile yarn test yarn build node scripts/verify-build.mjs yarn lint:firefoxCondition: steps.version.outputs.should_run == 'true'
  5. Upload to Chrome Web Storenpx --yes chrome-webstore-upload-cli@3 upload \ --source dist/chrome.zip \ --extension-id "$CWS_EXTENSION_ID" \ --client-id "$CWS_CLIENT_ID" \ --client-secret "$CWS_CLIENT_SECRET" \ --refresh-token "$CWS_REFRESH_TOKEN" \ --auto-publishCondition: steps.version.outputs.should_run == 'true'
  6. Upload to Firefox Add-ons (AMO)npx --yes web-ext sign \ --source-dir dist/firefox \ --channel listed \ --api-key "$WEB_EXT_API_KEY" \ --api-secret "$WEB_EXT_API_SECRET"Condition: steps.version.outputs.should_run == 'true'
  7. Create and push tagTAG="${{ steps.version.outputs.tag }}" git tag "$TAG" git push origin "$TAG"Condition: steps.version.outputs.should_run == 'true'
Release npm SDK · .github/workflows/release-sdk-npm.yml ↗

Triggers: push, workflow_dispatch

release · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. Resolve versionCURRENT=$(bash scripts/read-version.sh sdk/typescript/package.json) TAG="npm-v$CURRENT" # Decide whether to publish. Two idempotency layers, in order: # 1. Manifest-change-in-push: skip when the triggering push did # not touch the SDK manifest. Range-based so multi-commit # pushes work (HEAD~1 is wrong when the version bump is the # first of several commits in the push). # 2. Tag-exists: skip when the target tag already exists on # origin. Catches reruns of a successful release. # Manual workfl…
  3. Verify spec hash parity./.github/actions/verify-spec-hashCondition: steps.version.outputs.should_run == 'true'
  4. actions/setup-node@v7actions/setup-node@v7Condition: steps.version.outputs.should_run == 'true'
  5. Upgrade npm for trusted publishingnpm install -g --force npm@latestCondition: steps.version.outputs.should_run == 'true'
  6. Install + build + testyarn install --frozen-lockfile yarn build yarn testCondition: steps.version.outputs.should_run == 'true'
  7. npm publishnpm publish --access publicCondition: steps.version.outputs.should_run == 'true'
  8. Create and push tagTAG="${{ steps.version.outputs.tag }}" git tag "$TAG" git push origin "$TAG"Condition: steps.version.outputs.should_run == 'true'
Release pub.dev SDK · .github/workflows/release-sdk-pub.yml ↗

Triggers: push

release · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. Resolve versionTAG="${GITHUB_REF_NAME}" VERSION="${TAG#pub-v}" if [ -z "$VERSION" ] || [ "$VERSION" = "$TAG" ]; then echo "tag $TAG does not match expected pub-v<version> pattern" >&2 exit 1 fi MANIFEST_VERSION=$(bash scripts/read-version.sh sdk/dart/pubspec.yaml) if [ "$MANIFEST_VERSION" != "$VERSION" ]; then echo "tag version $VERSION does not match manifest version $MANIFEST_VERSION" >&2 exit 1 fi echo "current=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
  3. Verify spec hash parity./.github/actions/verify-spec-hash
  4. dart-lang/setup-dart@v1dart-lang/setup-dart@v1
  5. dart pub get + analyze + testdart pub get dart analyze --fatal-infos dart test --exclude-tags e2e
  6. Publish to pub.devdart pub publish --force
Release Python SDK · .github/workflows/release-sdk-python.yml ↗

Triggers: push, workflow_dispatch

release · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. Resolve versionCURRENT=$(bash scripts/read-version.sh sdk/python/pyproject.toml) TAG="py-v$CURRENT" # Decide whether to publish. Two idempotency layers, in order: # 1. Manifest-change-in-push: skip when the triggering push did # not touch the SDK manifest. Range-based so multi-commit # pushes work (HEAD~1 is wrong when the version bump is the # first of several commits in the push). # 2. Tag-exists: skip when the target tag already exists on # origin. Catches reruns of a successful release. # Manual workflow_…
  3. Verify spec hash parity./.github/actions/verify-spec-hashCondition: steps.version.outputs.should_run == 'true'
  4. actions/setup-python@v7actions/setup-python@v7Condition: steps.version.outputs.should_run == 'true'
  5. pip install + test + buildpython -m pip install --upgrade pip build pip install -e '.[dev]' pytest python -m buildCondition: steps.version.outputs.should_run == 'true'
  6. Publish to PyPIpypa/gh-action-pypi-publish@release/v1Condition: steps.version.outputs.should_run == 'true'
  7. Create and push tagTAG="${{ steps.version.outputs.tag }}" git tag "$TAG" git push origin "$TAG"Condition: steps.version.outputs.should_run == 'true'
Release · .github/workflows/release.yml ↗

Triggers: push, workflow_dispatch

release · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. Read version and check tagCURRENT=$(jq -r .version package.json) if [ -z "$CURRENT" ] || [ "$CURRENT" = "null" ]; then echo "Could not read version from package.json" >&2 exit 1 fi TAG="app-v$CURRENT" if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then echo "exists=true" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" fi echo "current=$CURRENT" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
  3. Extract changelog for versionbash scripts/extract-changelog.sh \ "${{ steps.version.outputs.current }}" \ CHANGELOG.md \ /tmp/release_notes.mdCondition: steps.version.outputs.exists == 'false'
  4. Guard release tag prefixTAG="${{ steps.version.outputs.tag }}" if [[ ! "$TAG" =~ ^app-v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then echo "::error::refusing to release tag '$TAG': the app release job only handles app-v<semver> tags" >&2 exit 1 fiCondition: steps.version.outputs.exists == 'false'
  5. Create tag and releaseTAG="${{ steps.version.outputs.tag }}" CURRENT="${{ steps.version.outputs.current }}" git tag "$TAG" git push origin "$TAG" # The tag keeps the app- prefix, since sdk-v/npm-v/py-v/pub-v tags # share the tag namespace. The release title drops it: with SDK # releases removed from the release page, "app-" on every title is # redundant, so the title is just the version. # --latest pins the sidebar slot to the app release no matter what # else exists in the release list. gh release create "$TAG" \ -…Condition: steps.version.outputs.exists == 'false'
SDK e2e · .github/workflows/sdk-e2e.yml ↗

Triggers: push, pull_request, workflow_dispatch

e2e · no job dependencies declared

  1. actions/checkout@v7actions/checkout@v7
  2. actions/setup-node@v7actions/setup-node@v7
  3. actions/setup-python@v7actions/setup-python@v7
  4. dart-lang/setup-dart@v1dart-lang/setup-dart@v1
  5. Install root dependenciesyarn install --frozen-lockfile
  6. Install TypeScript SDK dependenciesyarn install --frozen-lockfile
  7. Install Python SDK dev dependenciespython -m venv .venv .venv/bin/pip install --upgrade pip .venv/bin/pip install -e '.[dev]'
  8. Install Dart SDK dependenciesdart pub get
  9. Run cross-SDK e2e harnessscripts/test-sdks-e2e.sh
package.json ↗
  • migrations:bundle: tsx scripts/generate-migrations.ts
  • deploy: wrangler deploy
browser-extensions/package.json ↗
  • build: node build.mjs --target=all
  • build:chrome: node build.mjs --target=chrome
  • build:firefox: node build.mjs --target=firefox
sdk/typescript/package.json ↗
  • build: tsup src/index.ts --format cjs,esm --dts --clean
  • prepublishOnly: npm run build

Full upstream document by @oddbit · README.md · snapshot 745fc5c

SHRTNR. logotype

A programmable link layer for apps, teams and AI agents

npm PyPI pub.dev

shrtnr is a self-hosted URL shortener you drive from code and from AI assistants, not only from a dashboard. Every deployment ships a REST API with an OpenAPI spec, typed SDKs on npm, PyPI and pub.dev, and a native MCP server that Claude, Copilot and any other MCP client connect to through OAuth on Cloudflare Access. Links belong to the person who created them, several slugs can point at one destination, and bundles roll the clicks of a whole campaign into one report. It runs on Cloudflare Workers and D1, inside the free tier.

Deploy to Cloudflare

Claude creating and managing short links through the shrtnr MCP server

Who this is for

  • Teams that need per-person permissions, not a shared password. Sign-in runs through Cloudflare Access, so every teammate arrives with their own identity. Links and bundles record who created them, and only the creator can edit, disable or delete them. Everyone can read everything. API keys are issued per person and act as that person. Permission model.
  • Developers integrating links into an app. A REST API documented by an OpenAPI 3.1 spec, with a live reference at /_/api/docs on your deployment. Typed SDKs for TypeScript, Python and Dart, generated from that spec. Bearer keys with read and create scopes. Link creation is idempotent, and QR codes come back as SVG from one endpoint.
  • Anyone whose AI assistant should create and manage links. The MCP server at mcp.<your-domain> exposes tools to shorten URLs, attach slugs, group links into bundles and query analytics, annotated as read-only, idempotent or destructive. It authenticates through OAuth on Cloudflare Access, so there is no shared token to paste into a config file. MCP server.

Features

What sets it apart:

  • Native MCP server. Tools for links, slugs, bundles, QR codes and analytics, served from the same Worker. OAuth through Cloudflare Access Managed OAuth; every tool call runs as the signed-in user. Analytics tools echo the time range they used and apply the user's own bot and self-referrer filters. Setup and tool reference.
  • Bundles with combined analytics. Group the links of one campaign, launch or project. A bundle reports total clicks, a timeline, countries, referrers, devices, browsers and each link's share of the total, over any time range. A link can belong to several bundles. Archive a bundle when the campaign ends.
  • Several slugs per link. One destination can answer on a random slug and any number of custom slugs, for example one per channel. Each slug tracks its own clicks. Disable or enable a slug on its own, or pick which one is primary.
  • Ownership and permissions. Identity comes from Cloudflare Access. Creators own their links and bundles; anyone can read, and anyone can add a slug to a link or a link to a bundle. Settings such as theme, language and default range are stored per user. Access control.
  • Typed SDKs for TypeScript (@oddbit/shrtnr), Python (shrtnr) and Dart/Flutter (shrtnr). CI pins each SDK to the hash of the OpenAPI spec it was generated from, so an API change cannot ship without the SDKs moving with it.
  • REST API with an OpenAPI 3.1 spec at /_/api/openapi.json and an interactive reference at /_/api/docs. API keys are hashed at rest and scoped to read, create or both.

The rest of the shortener:

  • Click analytics by country, referrer URL and referrer host, device type, operating system, browser, and QR scan versus link click, with a timeline that adapts its buckets to the range (24 hours to all time). Bots and self-referrers are filtered out by default, per user.
  • Custom slugs and short random slugs. Random slugs start at 3 characters from a 32-character alphabet, which gives 32,768 combinations at that length. Custom slugs like /spring-sale sit alongside them.
  • Link expiry. Set expires_at on creation or later; an expired link answers 404.
  • Disable instead of delete. A link or slug with recorded clicks cannot be deleted, only disabled, so click history is never lost by accident. Disabling is reversible.
  • Idempotent creation. Shortening a URL that already has a link returns the existing link, after trailing-slash normalization. Pass allow_duplicate to force a second one.
  • Labels from the page title. A link created without a label gets the destination page's title fetched in the background, with private and internal hosts refused.
  • QR codes as SVG for any slug, from the admin UI, the API, the SDKs and MCP. Scans are tracked separately from link clicks.
  • Edge redirects. Slug lookups are cached in Workers KV in front of D1, and click recording runs after the redirect is sent.
  • Admin dashboard in English, Indonesian and Swedish, with three themes, per-link and per-bundle analytics, API key management and settings.
  • Browser extension for Chrome and Firefox that shortens the current tab against your own deployment. Source and store status in browser-extensions/README.md.
  • One-click deploy with automatic provisioning of the database and KV namespace, and migrations that the Worker applies on its first request.

Not yet: password-protected links, link import and export, routing by device or country, tags, social preview overrides, click webhooks, and a read-only MCP scope. UTM parameters are stored on every click but not yet reported in the dashboard.

Screenshots

Dashboard Bundle analytics Link analytics
Admin dashboard with 30-day analytics Combined analytics for a bundle of links Per-link analytics with slugs and breakdowns
Bundles API keys
Bundles overview API keys with read and create scopes

See it in action

Deploy

One-click

Click the Deploy to Cloudflare button. Cloudflare forks the repo into your GitHub or GitLab account, provisions the D1 database and KV namespace that wrangler.jsonc declares, and deploys the Worker through Workers Builds.

Deploy to Cloudflare

The Worker creates its own database schema on the first request, so there is no command to run afterwards. To confirm the deploy:

  1. Open https://<your-worker>.workers.dev/_/admin/dashboard. That first visit creates the schema. The page answers with the Access setup steps, since nothing protects the Worker yet.
  2. Open https://<your-worker>.workers.dev/_/health. It answers "schema": { "ready": true }.
  3. Follow Protect the admin UI: enable Access on the workers.dev URL in one click, then store the ACCESS_AUD and ACCESS_JWKS_URL secrets.
  4. Reload the dashboard, sign in through Access and create a link.

Every later push to your fork redeploys through Workers Builds, and the Worker applies any new migration on the first request after the deploy. See Database schema for how that works and what to check when it does not.

Manual

git clone https://github.com/oddbit/shrtnr
cd shrtnr
yarn install
yarn wrangler-login
yarn deploy

wrangler.jsonc declares the D1 database and KV namespace by name only. The first yarn deploy creates both in your account through wrangler's resource provisioning and later deploys link to them by binding name. Wrangler also writes the new IDs into wrangler.jsonc on your machine; discard that change, the IDs are specific to your account and the deploy works without them.

The first request creates the schema. To apply it ahead of that request from your terminal, run yarn db:migrate:remote; the Worker and the CLI record their work in the same table, so either can go first.

Continuous deployment

Cloudflare Workers Builds redeploys the Worker on every push to your production branch. Schema changes need no separate step: the deployed Worker carries its migrations and applies the pending ones on the first request.

The build settings the project expects, under Workers & Pages > shrtnr > Settings > Build in the dashboard:

Field Value
Build command empty
Deploy command yarn deploy (or npx wrangler deploy)
Version command npx wrangler versions upload

A fork created by the deploy button gets these from package.json. Both commands work with the bindings declared by name in wrangler.jsonc: wrangler links to the Worker's existing KV namespace and D1 database by binding name. A project set up before this repo dropped its id placeholders may still carry bash scripts/resolve-bindings.sh && ... in one of these fields; that script no longer exists, so remove that prefix or the build fails with "No such file or directory".

Two optional ways to apply migrations before the Worker takes traffic, for deployments that want the schema in place ahead of the first request:

  • GitHub Actions. .github/workflows/migrate.yml runs wrangler d1 migrations apply after Cloudflare's check suite succeeds on main. It needs two repository secrets under Settings > Secrets and variables > Actions: CLOUDFLARE_API_TOKEN with Workers Scripts: Edit and D1: Edit, and CLOUDFLARE_ACCOUNT_ID. A fork created by the deploy button can add the workflow and the secrets the same way.
  • Workers Builds deploy command. Set the project's deploy command to npx wrangler d1 migrations apply DB --remote && npx wrangler deploy. The token Workers Builds creates for itself holds Workers Scripts, KV and R2 edit rights; its documented permission list does not include D1, so add D1: Edit to that token under My Profile > API Tokens first, or the migration step fails with an authentication error.

Database schema

Migrations live in migrations/*.sql. yarn migrations:bundle (run for you by yarn dev) writes them into src/db/migrations.generated.ts, which ships inside the Worker; the vitest suite fails when the two disagree, so add a migration, run the bundler, and commit both.

On the first request an isolate receives, the Worker applies every migration that is not yet recorded in D1's d1_migrations table, the same table with the same file names that wrangler d1 migrations apply uses. Each migration runs as one transaction with its bookkeeping row, so a race between isolates on a fresh deploy ends with each migration applied once. After that first request the check is a settled promise, and a fresh isolate reads the recorded schema version from KV before it touches D1, so redirects pay nothing for it.

Two routes report on the schema, and neither changes it on a GET:

  • GET /_/health includes schema.version (what this build expects), schema.applied (the last recorded migration) and schema.ready. A database no request has reached yet reports ready: false with 200. After a failed migration attempt it answers 503 with "status": "degraded" and the error.
  • GET /_/setup lists applied and pending migrations. POST /_/setup retries the migration at once. Both require a Cloudflare Access identity once ACCESS_AUD is set, and are rate-limited to ten requests a minute per client before that.

When a migration fails, the Worker remembers the failure for 30 seconds before a request triggers another attempt, so a migration that fails against live data costs one failed statement batch per half minute, not one per request. What visitors see depends on the database:

  • No schema yet (a fresh deploy): every route answers a 503 page that names the failing migration and the database error. The usual cause is a Worker without a D1 binding named DB (check Settings > Bindings in the dashboard).
  • An older schema in place (an upgrade whose new migration fails): short links keep redirecting, since they read tables that already exist. The admin pages, the API and the MCP endpoint answer the 503 page instead, and /_/health reports degraded, so the operator sees the failure and visitors do not.

Protect the admin UI

The admin pages open only behind Cloudflare Access. Access supplies the login and the per-user identity that ownership, API keys and settings run on, and the Worker verifies every Access JWT itself. Enable Access on the workers.dev URL in one click, or add a self-hosted application for /_/admin/* on your custom domain. Then store the application's AUD tag as the ACCESS_AUD secret and your team's key URL as ACCESS_JWKS_URL. Until both are set, the admin pages answer a setup page and short links keep redirecting. Step-by-step instructions, the identity table and the full permission model are in docs/access-control.md.

MCP server

Point Claude, Copilot or any other MCP client at https://mcp.<your-domain> and sign in through Cloudflare Access when the browser opens. The endpoint needs its own subdomain and a second Access application with Managed OAuth turned on, plus the MCP_ACCESS_AUD secret. The one setting that catches people out is Cloudflare's "Block AI bots" rule, which has to be off for the zone. The setup walkthrough, client configuration snippets and the tool reference are in docs/mcp.md.

API

Authentication is determined by route prefix:

Route Auth Notes
/_/api/* Bearer token Public link-management API. Create keys from the admin UI under API Keys and pass them as Authorization: Bearer sk_....
/_/mcp (and mcp.<your-domain>) OAuth MCP endpoint for AI assistants. Auth handled by Cloudflare Access. See MCP server.
/_/admin/* Cloudflare Access Admin UI and admin-only API. The Worker verifies the Access JWT against ACCESS_AUD (see Protect the admin UI). Not callable with API keys.
/_/health Public Health check.

For full endpoint shapes, parameters, and example payloads, see the live API reference at /_/api/docs on your deployment, or fetch the OpenAPI 3.1 spec directly at /_/api/openapi.json. The spec is the source of truth: SDKs (TypeScript, Python, Dart) regenerate from it when the API changes.

SDKs

Shorten URLs, manage slugs and bundles, and read analytics from your own code. All three expose the same resource groups: links, slugs and bundles.

Development

yarn install
yarn types                       # binding and runtime types from wrangler.jsonc, git-ignored
cp .dev.vars.example .dev.vars   # local identity settings, git-ignored
yarn test
yarn dev

yarn types writes worker-configuration.d.ts, which the typecheck needs. Rerun it after changing wrangler.jsonc. yarn dev creates the local D1 database and KV namespace on start and the schema on the first request; yarn db:migrate:local applies the schema from the CLI instead.

Local sign-in

Cloudflare Access protects the admin pages in production. wrangler dev runs without it, and the admin pages still need an identity: writes are owner-gated and settings are stored per user. DEV_MODE=true in .dev.vars (copied from .dev.vars.example) tells the Worker it runs on a developer machine. Without it, the local admin pages answer the Access setup page. Pick one identity per browser:

http://localhost:8787/_/dev/login?as=you@example.com

This sets a dev_identity cookie for that browser only, so a second browser or a second Playwright context can act as a second owner. /_/dev/login without as shows a form; /_/dev/logout clears the cookie. Requests carrying no cookie fall back to DEV_IDENTITY from .dev.vars. Both routes answer 404 outside dev mode. A deploy never uploads .dev.vars, and a configured ACCESS_AUD wins over DEV_MODE, so a deployment exposes nothing.

SDK development

cd sdk
yarn install
yarn test
yarn build

See CONTRIBUTING.md for the test suites, the SDK parity rule and what a pull request needs.

Built by Oddbit

shrtnr is one of the open-source tools Oddbit built for its own use and released. Oddbit is a senior-led software studio in Indonesia with roots in Sweden, shipping Cloudflare, Firebase, Flutter and AI integrations for funded startups and scale-ups. If you want shrtnr deployed, customised or integrated into your stack, the same team does that: oddbit.id.

Oddbit logotype

License and attribution

If you fork or build on this project, keep the license, notice and attribution files intact. Apache 2.0 requires it.

Frequently asked about SHRTNR

What is SHRTNR?+

SHRTNR is a self-hosted Bitly/Rebrandly alternative built on the Cloudflare developer platform. Run a short-link dashboard and click reports behind Cloudflare Access.

What does SHRTNR replace?+

SHRTNR is listed as an alternative to Bitly, Rebrandly, Short.io. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does SHRTNR use?+

SHRTNR is built on D1, Durable Objects, KV, Workers.

How much does SHRTNR cost to run?+

The documented SHRTNR 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. Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes. Use the configured SQLite Durable Object classes within 100,000 requests/day, 13,000 GB-s duration/day, 5 million SQL rows read/day, 100,000 written/day and 5 GB storage; active sockets consume duration. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Use an eligible Cloudflare Access Free proof-of-concept entitlement and confirm its current user limits; paid Access seats are separate if needed. Check current Cloudflare pricing before deploying.

Is SHRTNR open source?+

The upstream repository declares the Apache-2.0 license. Read its terms at https://raw.githubusercontent.com/oddbit/shrtnr/745fc5cd586550ca203b900a7718ce61ef064efb/LICENSE. Source code and contributor credit are available at https://github.com/oddbit/shrtnr.

Discussion · 0

sign in to comment →
No comments yet — be the first.