Free template · Build it with Claude, Cursor, Lovable or any AI app builder

Link Shortener template

Our UTM tags are a mess and nobody knows which links still work.

Short links on your own domain with a UTM builder that only accepts your agreed values, QR codes, click counts without bots or link previews, and destination changes with a full history.

By Sam Claassen · Updated · 4 build phases · 19/19 acceptance checks passed

No.
021
Department
Marketing
Build time
2 hrs
Difficulty
Intermediate
Team size
2–50 people
Replaces
Bitly
Your builder needs
A database and sign-in, plus a scheduled job, sending email and links that work without a staff sign-in
Status
Build-tested Nov 4, 2026

Can you build your own link shortener instead of paying for Bitly?

Yes, for a team of 2 to 50 people who share links in email, social, ads and print. This blueprint specifies short links on your own domain, a UTM builder that only accepts your agreed sources, mediums and campaigns, QR codes counted separately as scans, click counts that leave out bots, link previews and repeat clicks, unlimited destination changes with a history, link expiry, and reports by campaign, source and medium. It is specified in four build phases with 19 acceptance checks. It leaves out landing pages, mobile deep linking, city and device analytics, and browser extensions.

LinksGO.NORTHWIND.EXAMPLELINKLAST 30 DAYSSSSpring sale email/SPRING · NEWSLETTER · EMAIL342 CLICKSSSSpring sale ad/SPRING-LI · LINKEDIN · PAID-SOCIAL128 CLICKSQFQ4 flyer/FLYER · PRINT · QR57 SCANS+ New link123

Built and tested from this blueprint

We sent the 4 build prompts below to an AI builder, in order, and checked every acceptance item in a real browser, resetting to the seed before each phase's checks. One final browser run on a fresh database passed all 19 checks, and the build has 151 automated tests. The test build used a database, development sign-in, an outbox, a development clock and a manually triggered daily job; short links were opened from cookie-less browsers and the QR codes were decoded by software. Production sign-in, real email delivery, a hosted scheduler and a real short domain were not tested. The build exposed 69 spec gaps, now fixed.

Download blueprint.md
Acceptance checks passed
19/19
Automated tests
151
Spec gaps found and fixed
69
Build phases
4

The full write-up of this build test, with every gap and what was not tested, is in the build-test report below.

Links on a fresh seed, signed in as Maya Lopez: /q4-podcast 19 clicks (expires Nov 7), /flyer 3 clicks and 57 scans, /spring-li 128, /spring 342 and the expired /webinar-may 0, each with its campaign, owner and a Copy button.
New link from https://northwind.example/demo?ref=partner#form with linkedin, paid-social, spring-sale-2026 and content "Carousel A": the preview shows ?ref=partner&utm_source=linkedin&utm_medium=paid-social&utm_campaign=spring-sale-2026&utm_content=carousel-a#form and the QR code for go.northwind.example/demo-carousel?qr=1.
The /spring Link page: 342 counted clicks over 30 days in a daily chart, 25 previews left out of the count, and top countries United States 210, United Kingdom 61, Canada 40 and Germany 31.
Campaign spring-sale-2026: 470 counted (470 clicks, 0 scans), /spring 342 and /spring-li 128, by source newsletter 342 and linkedin 128, by medium email 342 and paid-social 128, with 25 previews left out.
The Outbox after the daily job ran on the seed day and again the next Monday at 08:00: Maya's digest "2 links, 40 clicks" (/spring 29, /spring-li 11), Chris's "2 links, 2 clicks, 5 scans" and Chris's reminder "go.northwind.example/q4-podcast expires on Nov 7"; none for Sana.
Chris Doyle's /flyer Link page at 390 pixels wide: destination with print, qr and q4-launch tags, 3 clicks and 57 scans in the last 30 days, and the QR code. This is a Chromium viewport check, not a test on a physical phone.

Watch the build test

We gave Claude one Markdown spec for a link shortener for marketing teams of 2 to 50 people and had it build the tool in four phases. It passed all 19 acceptance checks in a real browser, along with 151 automated tests, and exposed 69 gaps in the spec, from a sign-in rule limited to the company email domain with no setting to hold the domain, to a one-time sign-in link that a Slack link preview could use up. What passed, what broke, what we didn't test, and when Bitly is still the better buy. 2:49.

Transcript

Here's how to reverse engineer Bitly from a written spec. Claude passed all nineteen checks, but my spec never said how to count the last thirty days.

We gave Claude one Markdown file. It copies the part of Bitly a marketing team needs. Short links on your own domain. A UTM builder that only accepts your agreed sources and mediums. QR codes, counted separately as scans. And click counts that leave out bots and link previews.

The core rule is counting. A visitor is counted only once on a link in thirty seconds. Link previews from Slack or LinkedIn, and other bots, are recorded but never counted. A QR code carries a small marker in the link, so a scan counts apart from a click. And no IP address is ever stored.

Claude built it in four phases, one prompt each. The foundation and the link builder. Redirects and counting. Reports and exports. Then reminders, the weekly digest and safeguards. After every phase, the acceptance checks ran in a real browser, from fresh seed data.

Here's what it built. The Links screen shows five seed links, with three hundred and forty-two clicks on spring. The new link form previews the final URL, with every UTM tag in order, and its QR code. The link page charts the clicks, with twenty-five previews left out. The campaign report adds up four hundred and seventy clicks by source and medium. And the outbox holds the weekly digests and an expiry reminder.

In one final run, on a fresh database, all nineteen checks passed, along with one hundred and fifty-one automated tests.

But the more useful result is what broke. The test build exposed sixty-nine places where Claude had to guess. Sign-in was limited to the company email domain, but no setting held the domain. A one-time sign-in link could be used up by a link preview in Slack. And the source and medium fields could be dropdowns, which made the check that refuses f b impossible to try. Every gap is now fixed in the spec.

Passing every check isn't the same as production-ready. We didn't test production sign-in, real email delivery, the daily job on a real scheduler, a real short domain, or redirect speed under load. And the QR codes were decoded by software, not scanned by a phone camera.

So is it worth building? Billed yearly, Bitly's Core plan is ten dollars a month, Growth twenty-nine and Premium one hundred and ninety-nine. Changing where a link points is limited to five, twenty or one hundred and ten times a month, and multiple users are an Enterprise addition. If one person makes a handful of links a month, Core may be enough. If your team shares links and keeps changing where printed QR codes point, build it.

Everything is in one file. The data model, nine rules, seed data, four phase prompts, and all nineteen checks, with the sixty-nine fixes already in.

The full spec is free at personal software factory dot com. If you build it, tell me what broke. I'm Sam. See you tomorrow.

Made with AI from the real test-build screenshots. Narration and the on-camera intro and outro: an AI voice clone and avatar of Sam Claassen (HeyGen). Music: "Chill Wave" by Kevin MacLeod (incompetech.com), licensed under Creative Commons Attribution 4.0.

Build it or buy Bitly?

Billed annually, Bitly's Core plan is $10 a month (100 links a month, 30 days of click data), Growth $29 (500 links, a custom domain, 4 months of data) and Premium $199 (3,000 links, 1 year of data). The free plan has ads and 5 links a month. Changing where a link points is limited to 5, 20 or 110 times a month on those plans, and multiple users are an Enterprise addition. Source: bitly.com/pages/pricing, checked October 2026.

What this blueprint keeps

Short links on your own domain with readable back-halves; a UTM builder that only accepts your agreed sources and mediums, with presets and campaigns; QR codes as PNG and SVG; destination changes without limits, each kept in a history; link expiry with a fallback page; click and scan counts that leave out bots, link previews and repeat clicks; reports by link, campaign, source, medium, country and referring site; CSV exports; team accounts with owners; a root redirect and a friendly not-found page.

What it leaves out

Landing pages and link-in-bio pages, mobile deep linking, city and device analytics, browser extensions and mobile apps, more than one short domain, an API for other tools, retargeting pixels, splitting traffic between destinations, and AI features.

Build it if

  • Several people need to create and edit links, which Bitly lists as an Enterprise addition.
  • You want UTM values checked against an agreed list, so reports aren't split by spelling.
  • You change where printed QR codes point more often than a plan's monthly limit allows.
  • You want click history kept as long as you like, without bots and link previews.

Buy Bitly if

  • You want landing pages, link-in-bio pages or a digital business card.
  • You need mobile deep linking, city and device analytics, or browser extensions.
  • One person creates a handful of links a month: Bitly's Core plan at $120 a year may be enough.
  • You don't want to run a redirect service that your printed QR codes depend on.

The blueprint

Paste the whole spec into your AI builder, then send each prompt in the Build plan section one at a time. Check each phase before moving on.

How to use this file: paste this whole document into your AI builder (Claude, ChatGPT, Cursor, Lovable, Replit, Type) as context. Then send the build prompts in section 15 one at a time. Do not move to the next phase until the acceptance checks for the current phase pass.


1. The problem

"Our UTM tags are a mess and nobody knows which links still work."

Marketing builds tracking links by hand from a spreadsheet, so analytics shows "Facebook", "facebook" and "fb" as three different sources. The short links live in one person's account. A flyer's QR code points at a page that was renamed last month. Nobody can say which campaign link actually got clicks, or how many of those clicks were link previews in Slack.

This blueprint is a link shortener on your own domain (such as go.yourcompany.com) with a UTM builder that only allows your agreed values, QR codes, click counts that leave out bots and link previews, destination changes with a full history, and reports by campaign, source and medium. Every link belongs to the team.

2. What the real product does

Research basis: Bitly's pricing page and plan comparison (bitly.com/pages/pricing), October 2026.

What we verified:

  1. Plans and prices, billed annually. Free (with ads): 5 links and 2 QR codes a month and 3 custom back-halves a month. Core: $10 a month, annual only ($120 a year), with 100 links and 5 QR codes a month, 30 days of click and scan data, a UTM builder, and link and QR code redirects. Growth: $29 a month billed annually ($348), with 500 links and 10 QR codes a month, a complimentary custom domain, branded links, 4 months of data and bulk creation. Premium: $199 a month billed annually ($2,388), with 3,000 links and 200 QR codes a month, 1 year of data, custom campaign-level tracking, city-level and device data, and mobile deep linking. Enterprise: custom pricing. The page says annual plans save up to 34%.
  2. Changing a destination is metered. The comparison lists redirects (changing where a link or QR code points) at 5 a month on Core, 20 on Growth and 110 on Premium.
  3. Data history by plan. 30 days on Core, 120 days on Growth, 1 year on Premium and 2 years on Enterprise.
  4. Teams. Enterprise adds multiple users and group permission management, high-volume API and webhook access, SSO and a dedicated customer success manager. Clicks and scans are unlimited on every plan.

What this blueprint keeps

Short links on your own domain with readable back-halves; a UTM builder that only accepts your agreed sources and mediums, with presets and campaigns; QR codes as PNG and SVG; destination changes without limits, each kept in a history; link expiry with a fallback page; click and scan counts that leave out bots, link previews and repeat clicks; reports by link, campaign, source, medium, country and referring site; CSV exports; team accounts with owners; a root redirect and a friendly not-found page.

What this blueprint deliberately leaves out

Landing pages and link-in-bio pages, mobile deep linking, city and device analytics, browser extensions and mobile apps, more than one short domain, an API for other tools, retargeting pixels, splitting traffic between destinations, and AI features.

3. Scope of v1

  • One short domain you control, pointed at the app. The admin screens live under /admin on the same domain.
  • Redirects must be fast: one indexed lookup (or a cache of at most 60 seconds) and a 302 response. Use 302, not 301, so browsers don't remember the destination and changes take effect.
  • Recording a click must not slow the redirect: write it after responding (a deferred write, queue or background job). A failed write is logged and never changes the redirect; queued clicks are written before the app shuts down.
  • Privacy: no cookies on redirects and no stored IP addresses (R7). Country comes from your host's geolocation header when it provides one (such as CF-IPCountry or X-Vercel-IP-Country, configurable); XX or an invalid value is stored as empty.

4. Roles

Role Who Can do
Admin Marketing lead Everything: settings, users, sources, mediums, presets, campaigns, and every link.
Member Everyone else on the team Create links, edit and archive their own links, create campaigns, see every link and every report.
Visitor Anyone who clicks Follow links.

The server enforces these permissions, not only the screens. Team members sign in with any method that proves they own an address at Settings.email_domain: the platform's built-in auth, Google sign-in, or a one-time link sent through the outbox that expires after 15 minutes and is used only when the person presses Sign in (so link previews can't use it up). Anyone whose address doesn't match an active User, including a deactivated user, sees "Ask an admin to add you."; deactivating someone ends their sessions.

5. Link shortener data model

All IDs are strings. Timestamps in UTC.

Settings (one row): short_domain (short links are always https://short_domain/slug), main_site_url (the root redirect and the not-found page's link), expired_fallback_url, allowed_destination_domains (optional list), time_zone, email_domain (the only domain team members sign in with), app_name (the name in email subjects and page titles).

User: id, name, email (unique, lowercase), role: admin | member, active.

UtmValue: id, kind: source | medium, value (lowercase letters, digits and hyphens), active.

Preset: id, name, source, medium, content (optional).

Campaign: id, value (such as spring-sale-2026, unique), name, owner_id, archived.

Link

  • id, slug (unique, the back-half), title, base_url, utm_source, utm_medium, campaign_id, utm_term, utm_content, destination_url (base URL with UTM parameters applied)
  • owner_id, created_at, expires_at (optional), status: active | expired | archived, notes

LinkChange (append-only): id, link_id, at, user_id, field, old_value, new_value.

Click (append-only): id, link_id, at, kind: click | scan | preview | bot | expired, counted (false for repeats, R6), country (two letters or empty), referrer_domain (the Referer's host name, lowercased and without www., from http or https referrers only; or empty), visitor_hash.

Event (append-only) and Notification (outbox): id, at, actor, type, data; and id, at (when it was queued), kind, to_email, subject, body, status: queued | sent | failed | logged, error.

6. Building the destination

The destination is the base URL plus utm_source, utm_medium, utm_campaign, and utm_term and utm_content when given, in that order. Other query parameters stay where they are, exactly as typed (not re-encoded); a #fragment stays at the end. Values are lowercased automatically and spaces become hyphens. Source and medium must be on the allowed lists, and the campaign must be an existing campaign. If the base URL already has UTM parameters (any parameter starting utm_, in any case), they are removed and replaced, with a warning; the link still saves, and the cleaned base URL is stored.

Worked examples (seed data in section 14)

Input Result
https://northwind.example/pricing with the "Newsletter" preset and campaign spring-sale-2026 https://northwind.example/pricing?utm_source=newsletter&utm_medium=email&utm_campaign=spring-sale-2026
https://northwind.example/demo?ref=partner#form with linkedin, paid-social, spring-sale-2026 and content "Carousel A" https://northwind.example/demo?ref=partner&utm_source=linkedin&utm_medium=paid-social&utm_campaign=spring-sale-2026&utm_content=carousel-a#form
Source "fb" Refused: "fb isn't an allowed source. Allowed: facebook, google, linkedin, newsletter, partner, podcast, print."
A base URL that already has utm_source=twitter Warning: "This URL already has UTM tags; they will be replaced."
Slug "spring" Refused: "go.northwind.example/spring is taken. Try spring-2."
Destination javascript:alert(1) Refused: "Links must start with http:// or https://."

7. Redirects and counting

Request Response Recorded as
/spring (active link) 302 to the destination click, counted
/spring again from the same visitor within 30 seconds 302 click, not counted
/spring from a link preview (user agent contains Slackbot, Twitterbot, LinkedInBot, facebookexternalhit, WhatsApp, Discordbot, TelegramBot or Applebot) 302 preview, not counted
/spring from another crawler (user agent contains bot, crawler or spider) 302 bot, not counted
/flyer?qr=1 (the address in the QR code) 302 to the destination, without qr=1 scan, counted
/webinar-may (expired) 302 to the expired fallback URL expired
/nothing-here or an archived link 404 page: "This link doesn't exist." with a link to the main site nothing
/ 302 to the main site nothing
HEAD requests Same status, no body nothing
Any method other than GET or HEAD 405 nothing

Slugs match without regard to case or a trailing slash: /Spring/ is /spring. Requests are classified in this order: expired, then preview, then bot, then scan or click; user agents match without regard to case. Only qr=1 has a meaning: other parameters on a short link are dropped, and qr with any other value is a normal click. A path that isn't a single slug-shaped segment, including reserved words, gets the 404 page.

8. Link shortener business rules

  • R1. Slugs. 3 to 50 characters: lowercase letters, digits and hyphens, unique. Reserved words are refused: admin, api, auth, login, logout, qr, static, assets, health, robots.txt, favicon.ico. Refusals read "Slugs must be 3 to 50 characters.", "Slugs can use only lowercase letters, digits and hyphens." and "admin is a reserved word. Choose another slug." (with the slug typed). A typed slug is lowercased and leading or trailing slashes are removed before these checks, so "/Spring/" is checked as spring. A slug can change only until its link has a counted click or scan at any time (previews, bots and expired requests don't count); after that it is permanent, because it may be printed, and a change is refused with "This slug is permanent because the link has counted clicks or scans (it may already be printed)."
  • R2. Destinations. http or https only, at most 2,000 characters for the final URL including its UTM tags ("Links can be at most 2,000 characters, including UTM tags."), and inside allowed_destination_domains when that list is set (a listed domain or any subdomain of it; an empty list allows every domain).
  • R3. UTMs are built as in section 6. Source and medium values come only from the active lists; term and content are lowercased, with spaces turned into hyphens.
  • R4. Destination changes. No limit. Every edit, from Phase 1 on, writes one LinkChange per changed field with the old and new values, and the next click uses the new destination. If the new destination is on a different domain (host names compared without regard to case or a leading www.; a subdomain counts as different) and the link had scans in the last 30 days, ask for confirmation: "This link is in a QR code that was scanned 12 times in the last 30 days." (with the real number of counted scans in the same 30-day window as Links; with none, there is no question). Pressing "Yes, change the destination" saves the change.
  • R5. Expiry. Expiry is entered as a date and time in the Settings time zone; a new or changed expiry must be in the future ("Choose an expiry in the future."). At expires_at the link redirects to the expired fallback; its stored status becomes expired on the next request, page load or minute check, recorded as an event by system. The owner is emailed once, by the first daily job run when the link expires within the next 72 hours (so a link given a 2-day expiry still gets one); the subject's date is in team time, and changing the expiry allows a new reminder.
  • R6. Counting. Counted clicks and scans exclude previews, bots, expired requests and repeats: the same visitor on the same link within 30 seconds of that visitor's last counted click or scan on it (clicks and scans count alike, so a scan and then a tap is one). Reports use counted clicks and scans only, and show previews separately.
  • R7. Privacy. visitor_hash is SHA-256 of a daily random salt plus IP address plus user agent. The salt is replaced at midnight in the Settings time zone (by the app clock) and the old one is discarded; it is kept only in memory, so hashes cannot be traced back or joined across days. IP addresses are never stored. The IP is the connection's address, or X-Forwarded-For only when the app runs behind a proxy you trust (TRUST_PROXY).
  • R8. Ownership. Members edit and archive only their own links; admins can edit any link. A link with counted clicks or scans is archived, never deleted. Because R9 keeps every click and change, only a link with no clicks of any kind and no change history can be deleted; any other delete request is refused (409). The owner or an admin can restore an archived link; it becomes active, or expired if its expiry has passed. An archived link returns the 404 page and keeps its slug, history and reports; it gets no reminders, and its earlier clicks still count in digests.
  • R9. Events. Every change writes an event; events, changes and clicks are never edited or deleted.

9. Link shortener screens

  1. Links (home): search by slug, title or destination; filter by campaign, owner, source, medium and status. Each row: short link with a copy button, title, destination host, campaign, counted clicks and scans in the last 30 days (today and the 29 days before it, in the Settings time zone, up to now; every screen uses the same window), owner.
  2. New link and Edit link: base URL, slug (suggested from the title), preset, source, medium, campaign, term, content, title, expiry, notes, with a live preview of the final URL and the QR code (the QR preview arrives with QR codes in Phase 2; Phase 1 shows a placeholder). Title, base URL, source, medium and campaign are required; a blank slug takes the suggestion from the title, with -2, -3 and so on if it's taken. Source and medium are typed, with the allowed values suggested, so a value off the list is refused with section 6's message; choosing a preset fills source, medium and content.
  3. Link page: the short link, copy, QR code download (PNG at 1,024 pixels and SVG), destination, change history, a daily chart of counted clicks and scans (30 or 90 days) and, for the same period, top countries and referring sites and the number of previews left out (bots, repeats and expired requests are shown too, on a smaller line). Top lists show 10 rows and sum the rest; a missing country shows as Unknown and a missing referrer as No referrer.
  4. Campaigns: each campaign with its links and their counted clicks and counted scans, shown separately and as a total, broken down by source and medium, for the last 30 days by default (90 days and all time can be chosen). Archived campaigns and campaigns with no links are listed too.
  5. Sources and mediums, Presets, Users and Settings (admins), and Outbox. Users are never deleted, only deactivated; admins can't demote or deactivate themselves, there is always at least one active admin, and new users need an address at the email domain. Sources and mediums can be added and turned on or off but not renamed or deleted; presets can be edited and deleted, because links copy their values.
  6. Public: the redirect, the not-found page and the root redirect.

10. Notifications

Trigger Recipient Subject
Link expires in 3 days Owner "go.northwind.example/q4-podcast expires on Oct 18"
Monday 08:00 in the Settings time zone (any run from Monday 08:00 until Tuesday 08:00 sends it, so a daily job at any fixed time sends it once) Each active user whose links had counted clicks or scans in the previous Monday 00:00 to Sunday 24:00, team time (credited to each link's current owner, whatever the link's status; deactivated users get none) "Your links last week: 2 links, 34 clicks", with ", 5 scans" added when there were scans; the link count covers links with counted clicks or scans that week, and the body lists each link's counted clicks and scans for that week
User added The new user "You've been added to Northwind links"

Write every email to the outbox first, then send it. Admins see every email in the Outbox and members see their own; in production, sign-in links are never shown there. With no provider configured, set status logged. A daily job (protected by a CRON_SECRET header: /api/cron/daily, GET or POST, with the secret in Authorization: Bearer or X-Cron-Secret; turned off when CRON_SECRET isn't set) sends reminders and digests, and is safe to run twice: each link gets one reminder per expiry date and each user one digest per week, even if two runs overlap.

11. Time

All "now" reads go through one function that adds the development clock offset, so expiry, reminders, the 30-second repeat window and digests can be tested. The development clock can be moved forward or set to any date and time; sign-in sessions and sign-in links expire on real time, and production ignores the offset.

12. QR codes and export

  • QR codes encode the short link with ?qr=1, so scans are counted separately from clicks. Use a standard library; error correction level M, the smallest version that fits and a 4-module quiet zone; downloadable as SVG and as a 1,024-pixel PNG. Any signed-in team member can download any link's codes.
  • Links export (CSV): short link, title, destination, source, medium, campaign, term, content, owner, created, status, counted clicks, counted scans (both of all time). The short link is the full https:// address, campaign is its value, owner is the owner's name, created is YYYY-MM-DD HH:MM in team time, and an empty term or content is blank.
  • Clicks export (CSV) for a date range: date, slug, counted clicks, counted scans, previews. Both exports follow the current Links filters; with none, every link is included, archived and expired ones too. A clicks export row exists for each link and day with a counted click, counted scan or preview; rows are sorted by date, then slug. Dates are whole days in team time, both ends included, at most 731 days; a malformed range or a start after the end is refused with a message. Files are UTF-8 with standard quoting, and a text cell starting with =, +, - or @ gets a leading apostrophe so spreadsheets don't run it.

13. Edge cases

  • Two people choose the same slug at the same moment: one succeeds and the other is told it is taken.
  • A base URL with spaces or without a scheme: add https:// and encode it; refuse it if it still doesn't parse. "//host" becomes https://host; any other scheme is refused with "Links must start with http:// or https://.", and a host without a dot (other than localhost) or a user name or password in the URL is refused.
  • A campaign is archived: its links keep working; it is no longer offered for new links.
  • A source or medium is deactivated: existing links keep it; new links cannot use it. An edited link may keep a deactivated source or medium, or an archived campaign, only while that field is unchanged.
  • A link's owner is deactivated: the link stays and an admin can reassign it. Its expiry reminders go to the active admins.
  • A visitor with no user agent: counted as a click (some apps send none).
  • An expired link's expiry is cleared or moved later: it becomes active again, recorded in its history.

14. Seed data

Company Northwind, a B2B software company. Short domain go.northwind.example, email domain northwind.example, app name "Northwind links", main site https://northwind.example, expired fallback https://northwind.example, time zone America/New_York. Dates are relative to today; the examples assume today is Thursday, October 15, 2026.

Users (emails firstname@northwind.example): Maya Lopez (admin, marketing lead), Chris Doyle (member, content), Sana Iqbal (member, events).

Sources: facebook, google, linkedin, newsletter, partner, podcast, print. Mediums: email, social, paid-social, cpc, referral, qr, audio.

Presets: Newsletter (newsletter, email), LinkedIn post (linkedin, social), LinkedIn ads (linkedin, paid-social), Print (print, qr).

Campaigns: spring-sale-2026 "Spring sale 2026" (Maya), webinar-may-2026 "Webinar May 2026" (Sana), q4-launch "Q4 launch" (Chris).

Link titles and created dates: /spring "Spring sale pricing (newsletter)", 40 days before the seed day; /spring-li "Spring sale pricing (LinkedIn ads)", 39 days before; /flyer "Q4 launch flyer", 35 days before; /q4-podcast "Q4 launch podcast ad", 34 days before; /webinar-may "May webinar registration", 45 days before it expired.

Link Destination base UTMs Owner Counted in the last 30 days
/spring https://northwind.example/pricing newsletter, email, spring-sale-2026 Maya 342 clicks (US 210, GB 61, CA 40, DE 31), plus 25 previews
/spring-li https://northwind.example/pricing linkedin, paid-social, spring-sale-2026, content carousel-a Maya 128 clicks
/flyer https://northwind.example/demo print, qr, q4-launch Chris 57 scans and 3 clicks
/q4-podcast https://northwind.example/launch podcast, audio, q4-launch; expires in 3 days (Oct 18 in the examples) Chris 19 clicks
/webinar-may https://northwind.example/webinars/may newsletter, email, webinar-may-2026; expired 136 days before the seed day (Jun 1 in the examples) Sana none (88 clicks in the 30 days before it expired, all older than 30 days)

Spread the seed clicks and scans evenly over the last 28 days, so they all stay inside the 30-day window. No seed click or scan has a referring site, only /spring's clicks have countries, and seed visitor hashes are placeholders. None of the seed links has a change history yet. Reset to seed data also sets the development clock to 09:00 on the seed day in the team time zone. Reset deletes and reloads all data, history included: the one development-only exception to R9. The seed users keep their IDs, so a signed-in tester stays signed in.

15. Link shortener build plan

Send these prompts one at a time after pasting this file. Each phase ends with checks you can run by clicking through the app. Start each phase's checks from a fresh load of the seed (the reset button); checks within a phase run in order on the same data.

Using the Link Shortener blueprint above, build Phase 1 only.

1. Set up the data model from section 5 exactly, with a real database.
2. Add sign-in under /admin with the platform's built-in auth or Google sign-in limited to our company email domain. A signed-in email must match an active User; anyone else sees "Ask an admin to add you."
3. Build Links, New link and Edit link (section 9, screens 1 and 2) with R1 to R3 and R8's ownership rule (archiving comes in Phase 4), and the admin screens in screen 5, plus a simple Campaigns list where anyone can add a campaign and its owner or an admin can archive or restore it (the Campaigns report is Phase 3), all enforced on the server. Do not build redirects yet.
4. Load the seed data from section 14, with dates relative to today, including the clicks.
5. Add a "sign in as" switcher, a "move the clock forward" control and a "reset to seed data" button (which also sets the clock to 09:00 on the seed day, team time; section 14), all visible only in development.

When you're done, list what you built and anything in the spec you had to interpret.

Acceptance checks

  • Links shows the five seed links; /spring shows 342 clicks in the last 30 days and /flyer shows 57 scans.
  • Building a link from https://northwind.example/demo?ref=partner#form with linkedin, paid-social, spring-sale-2026 and content "Carousel A" previews exactly the second URL in section 6's examples.
  • The source "fb", the slug "spring", the slug "admin" and the destination javascript:alert(1) are each refused with the messages in section 6 or R1.
  • Changing the slug of /spring is refused because it has clicks; changing the slug of a new link with no clicks works.
  • Signed in as Chris, he can edit /flyer but not /spring, and a direct request to edit /spring is refused.

Phase 2: Redirects and counting

Using the Link Shortener blueprint above, build Phase 2 only.

1. Build the public redirect, the not-found page and the root redirect exactly as section 7 describes, recording clicks after the response with R6 and R7.
2. Build QR codes (section 12) and destination changes with history (R4), and expiry (R5) without the email. Start the Link page (screen 3) with the short link, QR downloads, destination and change history; its charts come in Phase 3.

When you're done, list what you built and anything in the spec you had to interpret.

Acceptance checks

  • Opening /spring returns a 302 to the full pricing URL with its UTM parameters, and /spring's count goes from 342 to 343.
  • Opening it again within 30 seconds still redirects, but the count stays at 343; a request with the user agent "Slackbot-LinkExpanding 1.0" is recorded as a preview and not counted.
  • /flyer's QR code downloads as SVG and PNG and encodes the short link with ?qr=1; opening that address redirects without qr=1 and takes the scans from 57 to 58.
  • /webinar-may redirects to https://northwind.example and is recorded as expired; /nothing-here returns a 404 page with a link to the main site; / redirects to the main site.
  • Changing /spring-li's destination records the old and new URLs in its history, and the next click goes to the new destination.
  • No IP address is stored anywhere in the database.

Phase 3: Reports and exports

Using the Link Shortener blueprint above, build Phase 3 only.

1. Build the Link page charts and breakdowns (section 9, screen 3) and Campaigns (screen 4), using counted clicks and scans only.
2. Build both CSV exports (section 12).

When you're done, list what you built and anything in the spec you had to interpret.

Acceptance checks

  • After a reset, campaign spring-sale-2026 shows 470 clicks: newsletter 342 and linkedin 128, or email 342 and paid-social 128.
  • /spring's page shows the countries US 210, GB 61, CA 40 and DE 31, and 25 previews left out of the count.
  • Filtering Links by campaign q4-launch shows /flyer and /q4-podcast only.
  • The links export lists all five links with their UTM values, owner and counted clicks and scans; the clicks export for the last 30 days has one row per link per day with clicks.

Phase 4: Reminders, digest and safeguards

Using the Link Shortener blueprint above, build Phase 4 only.

1. Build the daily job with the expiry reminder and the Monday digest (section 10), and the Outbox screen.
2. Add the QR confirmation in R4 and archiving in R8.

When you're done, list what you built and anything in the spec you had to interpret.

Acceptance checks

  • After a reset, running the daily job emails Chris once that /q4-podcast expires in 3 days ("expires on Oct 18" in the examples); running it again sends nothing more.
  • Moving the clock to the next Monday at 08:00 or later (team time) and running the job puts a digest for Maya and Chris in the outbox with each of their links' counted clicks and scans for the previous week, and none for Sana.
  • Changing /flyer's destination to another domain asks for confirmation because it was scanned in the last 30 days.
  • Archiving /flyer makes it return the 404 page; links with counted clicks have no Delete action, and a direct delete request is refused.

16. Variations

  • A small API so other tools can create links with your UTM rules.
  • Split traffic between two destinations to test a page.
  • Several short domains, one per brand.
  • Send counted clicks to your analytics tool as events.
  • A browser bookmarklet that opens New link with the current page as the base URL.

17. Build notes from our test build

We built this blueprint by sending the four phase prompts above, in order, to an AI builder (Claude, working in Type, with a fresh session for each phase that saw only this spec and the code so far), then checked every acceptance item in a real browser, resetting to the seed data before each phase's checks. Where a check says something is refused, the person saw the reason on screen and a direct request got an error with nothing changed. Short links were opened from separate browser contexts with no cookies (one with Slack's link-preview user agent), the QR codes were downloaded and decoded, the CSV exports were downloaded and parsed, and emails were read on the Outbox screen.

Result: 19 of 19 acceptance checks passed in one final browser run on a fresh database, plus 151 automated tests. The item-by-item results are in acceptance-results.json.

Gaps the build found, now fixed in this spec:

  1. Sign-in was limited to "our company email domain", but no setting held the domain. Settings now has email_domain (northwind.example in the seed).
  2. "Built-in auth or Google sign-in" didn't say what counts when neither is available. Section 4 now accepts any method that proves the address, including a 15-minute one-time link sent through the outbox that is used only when the person presses Sign in, so link previews can't use it up.
  3. It wasn't clear when "Ask an admin to add you." appears. Anyone whose address doesn't match an active user sees it, including a deactivated user, and deactivating someone ends their sessions.
  4. The "You've been added to Northwind links" subject named an app that no setting held. Settings now has app_name, "Northwind links" in the seed.
  5. Members may create campaigns from Phase 1, but the Campaigns screen arrived in Phase 3. Phase 1 now builds a simple Campaigns list where anyone adds a campaign and its owner or an admin archives or restores it.
  6. Nothing protected the last admin or said whether users can be deleted. Users are never deleted, only deactivated; admins can't demote or deactivate themselves, there is always an active admin, and new users need an address at the email domain.
  7. The admin screens didn't say what can be changed. Sources and mediums can be added and turned on or off but not renamed or deleted; presets can be edited and deleted, because links copy their values.
  8. "Expired Jun 1" was a fixed date in a seed that is otherwise relative to today, and the 88 old clicks had no dates. /webinar-may now expired 136 days before the seed day (Jun 1 in the examples), with its 88 clicks in the 30 days before that.
  9. The seed gave no link titles, campaign names or created dates. It now gives all three.
  10. Only /spring's clicks had countries, and nothing said what the other seed clicks carry or whether any have a referring site. No seed click or scan has a referring site, only /spring's have countries, and seed visitor hashes are placeholders.
  11. A reset deletes history that R9 says is never deleted. Reset is now the one development-only exception to R9, and the seed users keep their IDs so the tester stays signed in.
  12. The development clock's controls and its effect on sessions weren't defined. It can be moved forward or set to any date and time; sessions and sign-in links expire on real time, and production ignores the offset.
  13. Screen 2 promised a QR preview while QR codes are Phase 2. Screen 2 now says Phase 1 shows a placeholder.
  14. No field was marked required and a blank slug had no rule. Title, base URL, source, medium and campaign are required; a blank slug takes the title's suggestion, adding -2, -3 and so on if it's taken.
  15. Source and medium could be dropdowns, which would make the "fb" refusal impossible to try. They are now typed with the allowed values suggested, and choosing a preset fills source, medium and content.
  16. Expiry had no time zone or limits. It is entered in the Settings time zone, and a new or changed expiry must be in the future ("Choose an expiry in the future.").
  17. R4 recorded destination changes only, so it wasn't clear whether Phase 1 edits leave history. Every edit, from Phase 1 on, writes one LinkChange per changed field.
  18. A check expected "the messages in section 6 or R1", but R1 gave no message for a reserved word or a bad slug. R1 now gives all three.
  19. R1 didn't say whether a typed slug is cleaned up. It is lowercased and leading or trailing slashes are removed before the checks.
  20. "Has a counted click or scan" didn't say over what period, or the refusal's wording. Any counted click or scan ever makes the slug permanent (previews, bots and expired requests don't), and R1 gives the message.
  21. "Replaced, with a warning" didn't say which tags count or whether the link still saves. Any utm_ parameter, in any case, is removed; the link saves and the cleaned base URL is stored.
  22. "Other query parameters stay where they are" didn't say whether they are re-encoded. They are kept exactly as typed.
  23. The 2,000-character limit didn't say whether it covers the UTM tags. It applies to the final URL, with its message.
  24. allowed_destination_domains didn't say whether subdomains pass. A listed domain or any subdomain of it passes, and an empty list allows every domain.
  25. "Add https://" didn't cover //host, other schemes, hosts without a dot or URLs with a password. //host becomes https://host, and the rest are refused.
  26. Editing a link whose source, medium or campaign was later turned off wasn't covered. An edited link keeps such a value only while that field is unchanged.
  27. short_domain had no scheme, so the copied link could be http or https. Short links are always https://short_domain/slug.
  28. The 30-second window didn't say what it counts from or whether a scan and a click are the same visit. It counts from that visitor's last counted click or scan on the link, and the two count alike.
  29. Section 7 didn't say which rule wins when a request is both, say, a preview and expired. Requests are classified expired, then preview, then bot, then scan or click, and user agents match without regard to case.
  30. "Replaced every day" didn't say which day or where the salt lives. It changes at midnight in the Settings time zone and is kept only in memory.
  31. R7 hashed "IP address" without saying which one behind a proxy. It is the connection's address, or X-Forwarded-For only behind a proxy you trust.
  32. "Your host's geolocation header" named no header. Section 3 now names CF-IPCountry and X-Vercel-IP-Country (configurable) and stores XX or an invalid value as empty.
  33. referrer_domain had no format. It is the Referer's host name, lowercased and without www., from http or https referrers only.
  34. Other query parameters on a short link, and qr with another value, weren't covered. Only qr=1 means anything; other parameters are dropped and qr=2 is a normal click.
  35. Paths with several segments or a reserved word had no rule. Anything that isn't a single slug-shaped segment gets the 404 page.
  36. Section 7 covered GET and HEAD only. Any other method now gets 405.
  37. A click write that fails after the response had no rule. It is logged and never changes the redirect, and queued clicks are written before the app shuts down.
  38. "Becomes expired" didn't say when the stored status changes or who the event's actor is. Redirects switch at expires_at; the status follows on the next request, page load or minute check, recorded as an event by system.
  39. Nothing said whether an expired link can come back. Clearing its expiry or setting a later one makes it active again.
  40. Section 12 gave no QR version, quiet zone or access rule. Codes use the smallest version that fits and a 4-module quiet zone, and any signed-in team member can download any link's codes.
  41. "The last 30 days" could be a rolling 720 hours or calendar days, and Phase 1 and Phase 3 chose differently. It is now today and the 29 days before it in the Settings time zone, on every screen.
  42. The change history that Phase 2 needed lives on the Link page, whose charts are Phase 3. Phase 2 now starts the Link page with the short link, QR downloads, destination and history.
  43. Campaigns had no period. It shows the last 30 days by default, with 90 days and all time to choose from.
  44. "Counted clicks" on Campaigns didn't say whether scans are included, though the check's 470 is a total. Campaigns shows counted clicks and scans separately and as a total.
  45. It wasn't said whether archived campaigns or campaigns with no links appear. Both are listed.
  46. The Link page didn't say which period the countries, referring sites and previews cover. They follow the chart's 30 or 90 days.
  47. "Previews left out" ignored the bots, repeats and expired requests that R6 also leaves out. Those are shown too, on a smaller line.
  48. "Top countries" had no length or labels for missing values. Top lists show 10 rows and sum the rest; a missing country is Unknown and a missing referrer is No referrer.
  49. The links export's counted clicks and scans had no period, so /webinar-may could be 0 or 88. They are of all time.
  50. The exports didn't say whether they follow the Links filters. Both do; with none, every link is included, archived and expired ones too.
  51. The links export gave columns but no formats. The short link is the full https:// address, campaign its value, owner the name, created YYYY-MM-DD HH:MM in team time, and empty term or content blank.
  52. "One row per link per day" didn't say which days get a row. A link has a row for a day with a counted click, counted scan or preview, sorted by date, then slug.
  53. The clicks export's date range had no rules. Dates are whole days in team time, both ends included, at most 731 days, and a malformed range or a start after the end is refused.
  54. Link titles and notes in a CSV could run as spreadsheet formulas. Files are UTF-8 with standard quoting, and a cell starting with =, +, - or @ gets a leading apostrophe.
  55. Phase 1 asked for R8, whose archiving comes in Phase 4. Phase 1 now builds R8's ownership rule only.
  56. Notifications had no time, so the Outbox couldn't show when an email was queued. Notification now has at.
  57. A daily job couldn't know whether "Monday 08:00" had passed in its run. A run from Monday 08:00 until Tuesday 08:00 team time sends the digest, so a daily job at any fixed time sends it once.
  58. "The previous Monday to Sunday" had no boundaries or owner rule. It runs Monday 00:00 to Sunday 24:00 team time, credited to each link's current owner; deactivated users get none.
  59. The digest subject had no place for scans. It adds ", 5 scans" when there were scans, and its link count covers links with counted activity that week.
  60. "Emailed 3 days before" didn't fit a daily job or a link made with a 2-day expiry. The first run with the expiry less than 72 hours away sends it once, and changing the expiry allows a new one.
  61. A deactivated owner's expiry reminder had nowhere to go. It goes to the active admins.
  62. "Safe to run twice" didn't say what must not repeat. Each link gets one reminder per expiry date and each user one digest per week, even if two runs overlap.
  63. "Protected by a CRON_SECRET header" named no endpoint or header. It is /api/cron/daily, GET or POST, with the secret in Authorization: Bearer or X-Cron-Secret, and off when CRON_SECRET isn't set.
  64. The Outbox had no rule on who sees which email. Admins see every email and members their own, and in production sign-in links are never shown there.
  65. "A different domain" didn't say how hosts compare. Host names compare without regard to case or a leading www., and a subdomain counts as different.
  66. The R4 question quoted "12 times" without saying which scans it counts or how to answer. It shows the real number of counted scans in the same 30-day window as Links, and "Yes, change the destination" saves it.
  67. R8 implied that links without counted clicks can be deleted, while R9 keeps every click and change forever. Only a link with no clicks of any kind and no history can be deleted, and any other delete request is refused with 409.
  68. Archiving had no undo. The owner or an admin can restore an archived link, which becomes active, or expired if its expiry has passed.
  69. Nothing said what else archiving changes. An archived link keeps its slug, history and reports, gets no reminders, and its earlier clicks still count in digests.

What we did not test:

  • Production sign-in. Company-domain one-time sign-in links were built, but every check used the development "sign in as" switcher.
  • Sending email. Expiry reminders, digests and welcome emails were logged in the outbox, not delivered.
  • The daily job on a real scheduler. It was run from the development control with the development clock; the cron endpoint was only checked by hand for its secret.
  • Hosting on a real short domain. The checks ran on localhost, so https, the host's country header (countries came only from the seed) and a trusted proxy's X-Forwarded-For were not exercised.
  • Redirect speed under load, and the deferred click write with many visitors at once.
  • Phone cameras. The QR codes were decoded by software from the PNG and from the SVG rendered in a browser, not scanned from paper.
  • Spec fixes that no acceptance check covers, such as 405 answers, CSV formula guarding, restoring an archived link and a deactivated owner's reminders. The test build handles them through its own choices.

Field report

Building a Bitly replacement with AI, 19 checks and 69 spec gaps

We had an AI builder build a team link shortener from our spec, one phase at a time, and checked every acceptance item in a real browser. It passed all 19 checks in one final run and exposed 69 gaps in the spec, the second-most of our twenty-two build tests. What it costs next to Bitly, and what we did not test.

The short answer

Yes, if your team wants short links on its own domain, agreed UTM values and click counts it can trust, without a per-plan limit on destination changes. We gave Claude, working in Type, our Link Shortener blueprint and had it follow the four phase prompts in order, with a fresh builder for each phase that saw only the spec and the code. The final build passed 19 of 19 acceptance checks in one browser run on a fresh database, with 151 automated tests. Redirects answer with a 302 and record the click afterwards, link previews and bots are left out of the counts, and no IP address is stored.

The build exposed 69 gaps in the spec, places where the builder had to guess. That is the second-most of our twenty-two build tests, behind the invoice reminders build (83) and ahead of the inventory tracker (63) and the status page (58), and far above the median of 11 for the other twenty-one. Every gap is now fixed in the published spec. The item-by-item results are published as acceptance-results.json.

What it is not: a tested production deployment. The last section lists exactly what was and wasn't checked.

What we built, and what we left out

We read Bitly's pricing page and plan comparison in October 2026 and kept what a marketing team uses every week:

  • Short links on your own domain with a root redirect and a not-found page.
  • A UTM builder that accepts only your agreed sources and mediums, with presets, campaigns and a live preview.
  • QR codes as SVG and a 1,024-pixel PNG, encoding the short link with ?qr=1 so scans are counted apart from clicks.
  • Counting that leaves out link previews, bots and repeat clicks, without storing IP addresses.
  • Reports by link, campaign, source, medium, country and referring site, two CSV exports, expiry reminders and a Monday digest.

Left out on purpose: landing pages, mobile deep linking, city and device analytics, browser extensions, extra domains and an API.

What it costs next to Bitly

Bitly's pricing page (bitly.com/pages/pricing, checked October 2026) lists, billed annually, a Core plan at $10 a month with 100 links a month and 30 days of click data, Growth at $29 with 500 links, a custom domain and 4 months of data, and Premium at $199 with 3,000 links and 1 year of data. The free plan has ads and 5 links a month. Changing where a link points is limited to 5, 20 or 110 times a month on those plans, and multiple users are an Enterprise addition.

For one person making a handful of links a month, Core at $120 a year may be enough. The build has no subscription price, but you run a redirect service your printed QR codes depend on. Build when several people need to make and edit links, or when you repoint printed codes more often than a plan allows. Our Bitly alternative page has the side-by-side.

How the build went, phase by phase

After each phase, its checks ran as Playwright scripts in Chromium, clicking through the app as the named person and opening short links from browsers with no cookies.

  1. Foundation and the link builder. All 5 checks passed after the only build bug we fixed: the live preview script wrote into the form instead of the preview panel.
  2. Redirects and counting. All 6 checks passed. /spring went from 342 to 343 clicks, a repeat and a Slackbot preview left it there, and a scan of /flyer's QR code took it from 57 to 58.
  3. Reports and exports. All 4 checks passed, with both CSV files downloaded and parsed.
  4. Reminders, digest and safeguards. All 4 checks passed, with each email read on the Outbox screen.

The final run reset to the seed before each phase and ran all 19 checks in one pass.

The 69 gaps, grouped

The full list, with each fix, is in the blueprint's build notes.

People, sign-in and the seed (12 gaps)

Sign-in was limited to "our company email domain" with no setting for the domain, again. The welcome email's subject named an app that no setting held. Members could create campaigns from Phase 1, but the Campaigns screen arrived in Phase 3. The seed had no link titles, campaign names or created dates, and /webinar-may's expiry was a fixed June date in a seed that is otherwise relative to today. A reset deletes history that R9 says is never deleted.

Lesson: a seed that moves with today must date everything relative to today, including its oldest record.

A check expected "the messages in section 6 or R1", but R1 gave none for a reserved word. Nothing said whether the 2,000-character limit covers the UTM tags, whether subdomains pass the allowed list, or whether a typed slug is lowercased first. If source and medium were dropdowns, nobody could type "fb" to be refused. "Has a counted click or scan" didn't say over what period.

Lesson: for every field a person types, write the cleaning, the limit and the exact refusal message.

Redirects, counting and QR codes (13 gaps)

The 30-second window didn't say what it counts from, or whether a scan and then a tap are one visit. Nothing said which rule wins when a request is both a preview and expired, which day the salt changes, which IP address counts behind a proxy, or which header carries the country. Other query parameters on a short link, methods other than GET and HEAD, and an expired link coming back were all unaddressed.

Lesson: a public endpoint needs a decision for every request it can receive, not only the ones in the table.

Reports and exports (14 gaps)

"The last 30 days" could be 720 hours or calendar days, and the first and third builders chose differently. Campaigns had no period, and a check expected 470 "clicks" where scans might count too. The links export's counts had no period, so /webinar-may could read 0 or 88. Neither export said whether it follows the Links filters, which days get a row, or how to keep a title from running as a spreadsheet formula.

Lesson: give every number on screen and in a file its period and its time zone.

Reminders, digest and archiving (15 gaps)

A daily job couldn't tell whether "Monday 08:00" had passed in its run, and "safe to run twice" didn't say what must not repeat. The digest subject had no place for scans. R8 implied that unused links can be deleted, while R9 keeps every click and change forever. Archiving had no undo, and a deactivated owner's expiry reminder had nowhere to go.

Lesson: write each scheduled email as a window and a key, not a time. The same pattern drove the employee onboarding and status page builds; see What AI app builders get wrong for every build.

What we did not test

  • Production sign-in. Company-domain one-time sign-in links were built, but every check used the development "sign in as" switcher.
  • Sending email. Every email was logged in the outbox, not delivered.
  • A real scheduler. The daily job ran from the development control with the development clock.
  • A real short domain. The checks ran on localhost, so https, the host's country header and a trusted proxy were not exercised.
  • Load and phone cameras. Redirect speed under load was not measured, and the QR codes were decoded by software, not scanned from paper.
  • Spec fixes no check covers, such as 405 answers, CSV formula guarding and restoring an archived link.

We list what every build left untested in Is an AI-built internal tool ready for production?.

Build it yourself

The Link Shortener blueprint is free: one Markdown file with the data model, 9 business rules, worked UTM examples, the redirect table, screens, seed data, the four phase prompts and all 19 acceptance checks, with the 69 fixes already in. Paste it into Claude, ChatGPT, Cursor, Lovable, Replit or Type and send the phases one at a time. If you find a gap we missed, tell us on the About page and we'll fix it and credit it in the build notes.

Link Shortener: questions people ask

How do you build a link shortener?

Store each short link as a slug and a destination, look up the slug on every request and answer with a 302 redirect, and record the click after responding so the redirect stays fast. Use 302 rather than 301 so browsers don't remember the destination and you can change it later. This blueprint adds reserved slugs, case-insensitive matching, a not-found page, link expiry and a change history.

How do you count clicks without counting bots?

Leave out requests whose user agent belongs to a link preview (Slack, LinkedIn, Facebook, X, WhatsApp and others) or a crawler, and count the same visitor on the same link only once in 30 seconds. In this blueprint the visitor is identified by a hash of a daily random salt, the IP address and the user agent, so no IP address is ever stored.

How do you keep UTM tags consistent?

Only allow agreed values: lists of sources and mediums that an admin maintains, campaigns chosen from a list, everything lowercased with hyphens instead of spaces, and saved presets for common combinations. The builder refuses anything else, so analytics never shows facebook, Facebook and fb as three sources.

How are QR code scans tracked separately from clicks?

Encode the short link with a marker such as ?qr=1 in the QR code. When the redirect sees the marker it records a scan instead of a click, and removes the marker before redirecting. Because the QR code points at your short link, you can change the destination after printing.

What data model does a link shortener need?

Settings, User, UtmValue (allowed sources and mediums), Preset, Campaign, Link (slug, base URL, UTM fields, destination, owner, expiry, status), LinkChange (append-only history), Click (kind, counted flag, country, referring site, visitor hash), an Event log and a Notification outbox.

How much does Bitly cost?

Billed annually, Core is $10 a month ($120 a year), Growth $29 ($348) and Premium $199 ($2,388); Enterprise is custom, and a free plan with ads allows 5 links a month (checked October 2026). The page says annual plans save up to 34%.

Get new blueprints by email

One email when a new blueprint is published. Nothing else.

More blueprints