<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: cligc</title>
    <description>The latest articles on DEV Community by cligc (@cligc).</description>
    <link>https://dev.to/cligc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191744%2Fb9e3d9ad-82d2-418b-aab1-4962933e4ed6.png</url>
      <title>DEV Community: cligc</title>
      <link>https://dev.to/cligc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cligc"/>
    <language>en</language>
    <item>
      <title>I built a free, self-hosted URL shortener on Cloudflare Workers + D1</title>
      <dc:creator>cligc</dc:creator>
      <pubDate>Sun, 18 Oct 2026 09:13:21 +0000</pubDate>
      <link>https://dev.to/cligc/i-built-a-free-self-hosted-url-shortener-on-cloudflare-workers-d1-4i1g</link>
      <guid>https://dev.to/cligc/i-built-a-free-self-hosted-url-shortener-on-cloudflare-workers-d1-4i1g</guid>
      <description>&lt;p&gt;Most link shorteners either cost money once you need analytics, or send your visitors' data to someone else's servers. I wanted one that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;runs on my own Cloudflare account, for free,&lt;/li&gt;
&lt;li&gt;redirects at the edge (fast everywhere),&lt;/li&gt;
&lt;li&gt;shows real analytics (not just a click counter),&lt;/li&gt;
&lt;li&gt;and can tell me how my &lt;em&gt;sites&lt;/em&gt; are doing, not only the links.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/ccvar/cligc_link" rel="noopener noreferrer"&gt;cligc link&lt;/a&gt;&lt;/strong&gt; — an open-source (MIT) short-link service that runs entirely on &lt;strong&gt;Cloudflare Workers + D1&lt;/strong&gt;. No server, no container, no database to manage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gd7hb1998oqgmg21vo7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9gd7hb1998oqgmg21vo7.webp" alt="Site list" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Short links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Route by &lt;strong&gt;country / region and device&lt;/strong&gt;: Hong Kong visitors go to one page, iPhone users to the App Store, everyone else to the default URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B tests&lt;/strong&gt;: split any destination by weight; the same visitor always gets the same variant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QR codes&lt;/strong&gt; for every link (SVG or 1024 px PNG)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open apps directly&lt;/strong&gt;: a link can point to &lt;code&gt;tg://&lt;/code&gt;, &lt;code&gt;weixin://&lt;/code&gt; and other app schemes, with a web fallback if the app isn't installed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks&lt;/strong&gt;: if a destination starts returning 404 or times out, you get a Telegram / email alert&lt;/li&gt;
&lt;li&gt;Edit history with one-click undo, expiry dates, bulk replace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Analytics&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real-time visits, sources, countries, devices, hourly distribution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human vs. bot detection&lt;/strong&gt; (crawlers, link previews, data-center traffic)&lt;/li&gt;
&lt;li&gt;Source tracking with &lt;code&gt;?src=&lt;/code&gt; and optional UTM tagging, so GA4 can show conversions per link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sites &amp;amp; SEO&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connect Google Analytics 4, Search Console and Bing Webmaster: every site's traffic and search performance on one page&lt;/li&gt;
&lt;li&gt;Checks whether each URL is indexed by Google and Bing, and can submit missing URLs to Bing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suggestions&lt;/strong&gt; generated from your own data: GA4 suddenly reporting nothing, clicks dropping, high impressions with low CTR, low indexing rate, broken URLs…&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmx5baw8t8d9wl4dz3gwj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmx5baw8t8d9wl4dz3gwj.webp" alt="Site page with suggestions" width="799" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;visitor ──► Cloudflare edge ──► Worker ──► 302 to the destination
                                   │
                                   └──► D1: visit record (async)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One Worker&lt;/strong&gt; handles everything: redirects, the admin SPA (a single HTML file), the JSON API and an hourly &lt;strong&gt;Cron Trigger&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; (Cloudflare's SQLite) stores links, rules and visit records. The schema is created automatically on the first request, so there's no migration step when you deploy.&lt;/li&gt;
&lt;li&gt;The redirect happens first; recording the visit goes through &lt;code&gt;ctx.waitUntil&lt;/code&gt;, so logging never slows down the redirect.&lt;/li&gt;
&lt;li&gt;The hourly cron &lt;strong&gt;aggregates visits into hourly / daily tables&lt;/strong&gt;, so charts read a few aggregate rows instead of scanning every visit. Anything after the last aggregation is read from the raw table, so stats are always real-time.&lt;/li&gt;
&lt;li&gt;The product home page is built as &lt;strong&gt;static assets&lt;/strong&gt;, so serving it doesn't count against Worker requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deploy it in about 5 minutes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option 1:&lt;/strong&gt; click the &lt;strong&gt;Deploy to Cloudflare&lt;/strong&gt; button in the &lt;a href="https://github.com/ccvar/cligc_link" rel="noopener noreferrer"&gt;README&lt;/a&gt;. It creates the Worker and the D1 database for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2:&lt;/strong&gt; command line (Node.js 18+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ccvar/cligc_link.git
&lt;span class="nb"&gt;cd &lt;/span&gt;cligc_link
npm &lt;span class="nb"&gt;install

&lt;/span&gt;npx wrangler login
npx wrangler d1 create cligc-link   &lt;span class="c"&gt;# put the printed database_id in wrangler.toml&lt;/span&gt;
npx wrangler deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open &lt;code&gt;https://&amp;lt;your-worker&amp;gt;/admin&lt;/code&gt; and sign in. The first sign-in forces you to set a new password (or set one before deploying with &lt;code&gt;npx wrangler secret put ADMIN_PASSWORD&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;To use your own domain (it must be on Cloudflare), add a route to &lt;code&gt;wrangler.toml&lt;/code&gt; and deploy again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;routes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="err"&gt;{&lt;/span&gt; &lt;span class="py"&gt;pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"go.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;custom_domain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Is it really free?
&lt;/h2&gt;

&lt;p&gt;For most individuals and small teams, yes. The free plan gives you 100,000 Worker requests a day, and D1 gives you 5 million rows read and 100,000 rows written a day.&lt;/p&gt;

&lt;p&gt;The honest catch: &lt;strong&gt;D1 writes run out first.&lt;/strong&gt; Each visit writes one record, but D1 also counts a row for every index and the autoincrement counter. Add the link's counter and the hourly aggregates, and a visit costs about &lt;strong&gt;10 written rows&lt;/strong&gt;. That means the free plan can fully record roughly &lt;strong&gt;several thousand to 10,000 visits a day&lt;/strong&gt;. Beyond that, the Workers Paid plan ($5/month) includes 50 million written rows a month.&lt;/p&gt;

&lt;p&gt;The admin shows your Cloudflare usage and forecasts when you'll run out, and can alert you before you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I learned building on Workers + D1
&lt;/h2&gt;

&lt;p&gt;A few things that weren't obvious from the docs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;D1 limits a statement to 100 bound parameters.&lt;/strong&gt; &lt;code&gt;WHERE id IN (?, ?, …)&lt;/code&gt; breaks as soon as a list gets long. Passing a JSON array as a single parameter and using &lt;code&gt;IN (SELECT value FROM json_each(?))&lt;/code&gt; fixes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write amplification is real.&lt;/strong&gt; Every index on a table is another written row per insert, so each index is a cost to weigh against the reads it speeds up. Most reads go to small aggregate tables instead of the raw visits table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the D1 daily quota is exhausted, &lt;em&gt;every&lt;/em&gt; query fails&lt;/strong&gt; until 00:00 UTC — including the ones your redirects depend on. Design for it: show a clear "temporarily unavailable" page and warn the owner &lt;em&gt;before&lt;/em&gt; it happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Worker invocation can only have 6 outbound connections open at once.&lt;/strong&gt; For slow APIs (Google's URL Inspection API takes several seconds per call), the time budget matters more than the request limit. Running independent API calls concurrently (Google and Bing checks side by side) roughly doubled throughput.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hourly cron runs have a wall-clock limit&lt;/strong&gt;, so long jobs need a deadline measured from when the run &lt;em&gt;started&lt;/em&gt; — including any time spent waiting for a lock — not from when the work began.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;*.workers.dev&lt;/code&gt; is usually blocked in mainland China.&lt;/strong&gt; If you have visitors there, put the Worker on your own domain before sharing links.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;strong&gt;&lt;a href="https://github.com/ccvar/cligc_link" rel="noopener noreferrer"&gt;https://github.com/ccvar/cligc_link&lt;/a&gt;&lt;/strong&gt; (MIT)&lt;/li&gt;
&lt;li&gt;Product page: &lt;strong&gt;&lt;a href="https://g.cligc.com" rel="noopener noreferrer"&gt;https://g.cligc.com&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin is available in English and Chinese. Issues and PRs are welcome, and if it's useful to you, a ⭐ on GitHub helps more people find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also by me:&lt;/strong&gt; &lt;a href="https://cligc.com" rel="noopener noreferrer"&gt;cligc.com&lt;/a&gt; — 170+ free tools that run entirely in your browser (JSON formatter, image compressor, PDF merge, QR code generator…). No ads, no sign-up, no uploads.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>opensource</category>
      <category>serverless</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
