Vibecode Bitly
track this build5 phases, 15 steps, beginner friendly0%A slug, a lookup, a 302, and a row in a table. Cloudflare Workers plus KV and D1 does the whole job on the free tier, and QR codes are one library call. The only thing you are really buying is that bit.ly resolves links created in 2012 and will presumably still resolve them in 2032, which is an institutional promise rather than a technical one. If the links are going in a magazine ad or on packaging, that promise is worth something. If they are going in this week's newsletter, it is worth nothing, and Growth is 420 USD a year.
You are building a lean indie version of Bitly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Bitly · indie build A redirect service mapping short codes to destinations with a fast redirect path, click logging that records device class and country without identifying anyone, QR codes generated for any link, and an export that lets the links survive the software. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | the redirect is the hot path and everything else is administration | | Redirect | A temporary redirect with the lookup served from memory | a redirect that waits on a database round trip is slower than the page it points at | | Codes | Generated random codes, not sequential | sequential codes let anyone enumerate every link you have ever made | | Analytics | Device class and country derived at request time, never a stored identifier | you can count clicks without building a record of who clicked | | QR | Generated at request time with error correction sized to the code | a QR code printed too small or with too little correction fails in the real world | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Go or Node.js 22 or newer** · free - Why: The redirect is a hot path worth being able to profile confidently. - Get it: go.dev or nodejs.org. - Verify: The toolchain builds and runs a hello world - [ ] **A short domain you will keep for a decade** · varies by extension; short ones cost more, and this is a long commitment - Why: This is the real prerequisite. Every link depends on the domain continuing to exist, and links outlive intentions. - Get it: Register something short you will not resent renewing. Set auto-renew immediately and record who else can renew it. - Verify: The domain auto-renews and a second person knows how - [ ] **PostgreSQL 16 or newer** · free - Why: Links and aggregated counts. The redirect path does not touch it, but everything else does. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A country lookup database you may use** · free at country level with an open database - Why: Country is derived at request time from the address, which is then discarded. The database has to be one you are licensed to use. - Get it: An openly licensed country-level database is sufficient; you do not need city precision and should not store it. - Verify: A known address resolves to the right country - [ ] **A printer, for the QR phase** · free - Why: Printed codes fail for reasons that are invisible on screen: size, quiet zone and contrast. The only test is a printed one. - Get it: Any printer. Print at the size you intend to use and scan from the intended distance. - Verify: A printed code scans at real size ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered - bundled QR code management, landing pages, and campaign grouping - city-level and device-level analytics rollups without building them - an SLA and someone to call If one of those is essential to you, that is the reason to keep paying for Bitly, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Bitly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener you run, with click analytics that do not track people to replace Bitly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A short link is a permanent commitment. Every link you create ends up in printed material, other people's documents, messages you cannot edit and pages you do not control. If the domain lapses or the service stops, every one of them breaks at once and there is no way to fix them. So decide before you shorten anything: is this domain one you will renew for a decade, and is there a plan for the links if you stop running the service? Keep a redirect map exportable as a static file, so at minimum the links can be served by something else. Know also that shorteners are treated with suspicion by spam filters and some platforms, because they hide the destination. Your own short domain has no reputation at all, which is worse than a recognised one in some contexts and better in others. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · the redirect is the hot path and everything else is administration - Redirect: A temporary redirect with the lookup served from memory · a redirect that waits on a database round trip is slower than the page it points at - Codes: Generated random codes, not sequential · sequential codes let anyone enumerate every link you have ever made - Analytics: Device class and country derived at request time, never a stored identifier · you can count clicks without building a record of who clicked - QR: Generated at request time with error correction sized to the code · a QR code printed too small or with too little correction fails in the real world ### Data model (create this before Phase 1) - links(id, code, destination, created_at, created_by, expires_at, disabled_at, title) - clicks(link_id, at, device_class, country, referrer_host) · no address, no identifier, no fingerprint - daily_counts(link_id, on_date, device_class, country, count) · aggregated, which is what the interface reads - domains(id, host, verified_at) · if you serve several short domains - The clicks table holds no value that identifies a person. That is a deliberate limit and it is what makes the analytics honest. ### Phase 1 · A redirect that is faster than the page Build: A lookup serving redirects from an in-memory map refreshed from the database, returning a temporary redirect. Measure the response time under load. Handle an unknown code with a proper not-found rather than a redirect to a home page. Trap: Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. Trap: A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. Done when: A redirect responds in single-digit milliseconds under load, measured rather than assumed. Changing a destination takes effect within the refresh interval for new visitors. An unknown code returns a not-found rather than redirecting anywhere. The redirect does not wait on writing the click. Do not build yet: No analytics, QR codes or administration. ### Phase 2 · Codes nobody can enumerate Build: Generate random codes from an alphabet excluding characters that are confused when read aloud or typed. Check for collisions on insert. Allow a custom code where you want one, with reserved words blocked. Trap: Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. Done when: Codes are random and knowing several reveals no pattern. A collision is detected on insert and retried rather than overwriting. The alphabet excludes easily confused characters, verified by reading a code aloud to someone who types it correctly. Reserved paths cannot be claimed as custom codes. Do not build yet: No analytics or QR codes yet. ### Phase 3 · Counting clicks without tracking people Build: Derive device class and country at request time and store only those with a timestamp. Store no address, no identifier and no fingerprint. Aggregate into daily counts and delete raw click rows after a short window. Trap: Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. Done when: The clicks table contains no address or identifier, verified by inspecting the schema. Country and device class are derived at request time and the input discarded. Raw clicks aggregate into daily counts and are deleted after the retention window. A click from a known address produces a country without that address appearing anywhere. Do not build yet: No QR codes or administration yet. ### Phase 4 · QR codes that work on a poster Build: Generate QR codes with error correction chosen for the use, output as SVG for print and PNG for screen, with a quiet zone. Test by printing one at its intended size and scanning it from the distance it will be scanned from. Trap: A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. Done when: A generated code scans from a phone at the intended distance after being printed at the intended size. The quiet zone is present in the output. A code with a long destination remains scannable, or the tool warns that the destination is too long for the chosen size. SVG output scales without losing sharpness. Do not build yet: No administration or export yet. ### Phase 5 · Managing links without breaking them Build: Edit a destination, set an expiry, and disable a link with a page explaining it rather than a bare error. Never delete a code in a way that lets it be reused. Record who created and last changed each link. Trap: Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. Done when: Editing a destination redirects new visitors to the new place. A disabled link shows an explanatory page rather than a bare error. A retired code cannot be reissued, verified by attempting it. Every link records who created it and when it last changed. Do not build yet: Export and operations come last. ### Phase 6 · Making the links outlive the software Build: Export the redirect map as a static file that any web server or hosting platform can serve. Back up the database. Document what happens to the links if the service stops, and set a calendar reminder for the domain renewal. Trap: The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. Done when: A static redirect map exports in a format a common host can serve, and it has been tested by actually serving it. The database backs up nightly. The domain is set to auto-renew and a reminder exists. The documentation states what happens to links if the service stops. Do not build yet: Nothing after this. ### Out of scope (and why) - The recognition of an established short domain, which cuts both ways: recognised shorteners are trusted by people and increasingly distrusted by spam filters. - Longevity. A commercial shortener's links have outlived many of the services that used them, and yours last exactly as long as you keep paying for the domain. - Campaign grouping, landing pages and the marketing features bundled into the paid tiers. - Deep click analytics with individual journeys, which this deliberately refuses in favour of counting without identifying. ### README must contain - That every short link is a permanent commitment and what happens if the domain lapses - That redirects are temporary rather than permanent, and why - That codes are random so the link list cannot be enumerated - That clicks store no address or identifier, and what is derived instead - That a retired code is never reissued ===== AGENTS.md ===== # Agent instructions · Bitly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Go or Node 22 with PostgreSQL, A temporary redirect with the lookup served from memory, Generated random codes, not sequential, Device class and country derived at request time, never a stored identifier, Generated at request time with error correction sized to the code. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ===== BUILD_PLAN.md ===== # Build plan · Bitly A redirect service mapping short codes to destinations with a fast redirect path, click logging that records device class and country without identifying anyone, QR codes generated for any link, and an export that lets the links survive the software. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A redirect that is faster than the page Following a short link adds almost nothing to the load time. ### Steps 1. Serve lookups from memory Refreshed in the background. The redirect path touches no database. Files: `src/redirect.go` 2. Use a temporary redirect So a destination change is possible. Permanent redirects are cached beyond your reach. Files: `src/redirect.go` 3. Log the click asynchronously Queued, not awaited. The visitor should not wait for your analytics. Files: `src/clicks.go` ### Done when - [ ] Redirect responds in single-digit milliseconds under load - [ ] A destination change takes effect within the refresh interval - [ ] An unknown code returns not-found - [ ] The redirect does not wait on click logging ### Watch out - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. ## Phase 2 · Codes nobody can enumerate Knowing one short code tells you nothing about the others. ### Steps 1. Generate random codes From an alphabet without confusable characters. Length chosen from your expected volume. Files: `src/codes.go` 2. Detect collisions on insert A unique constraint plus a retry. Never overwrite an existing code. Files: `migrations/` 3. Block reserved paths Your own administrative routes and anything you might want later. Files: `src/codes.go` ### Done when - [ ] Codes reveal no pattern across several links - [ ] A collision retries rather than overwriting - [ ] A code read aloud is typed back correctly - [ ] Reserved paths cannot be claimed ### Watch out - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. ## Phase 3 · Counting clicks without tracking people You learn how a link performed without building a record of who clicked it. ### Steps 1. Derive and discard Country and device class at request time. The address never reaches storage. Files: `src/clicks.go` 2. Aggregate into daily counts What the interface reads. Raw rows are transient. Files: `src/aggregate.go` 3. Delete raw rows on schedule Short window. Keeping them is a liability without a benefit once aggregated. Files: `src/retention.go` ### Done when - [ ] No address or identifier appears in the schema - [ ] Country is derived without the address being stored - [ ] Raw clicks aggregate and are deleted on schedule - [ ] Counts match a manual count for a test link ### Watch out - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. ## Phase 4 · QR codes that work on a poster A generated code scans reliably at the size it will actually be printed. ### Steps 1. Generate with a proper quiet zone Included in the output rather than assumed to be added by whoever places it. Files: `src/qr.go` 2. Choose error correction for the use Higher for print where a code may be scuffed, lower for screen where it will not. Files: `src/qr.go` 3. Print and scan at real size On paper, at the distance people will stand. Screen testing hides the failures. ### Done when - [ ] A printed code scans at its intended size and distance - [ ] The quiet zone is present in the output - [ ] A long destination warns or still scans - [ ] SVG output scales cleanly ### Watch out - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. ## Phase 5 · Managing links without breaking them You can edit and retire links without turning a working link into a dead one. ### Steps 1. Support edit, expiry and disable Three different states with three different behaviours. Files: `src/links.go` 2. Never reissue a code Retired means gone. Reuse sends people somewhere they did not intend to go. Files: `src/codes.go` 3. Explain a dead link A page saying the link has expired beats a generic error for anyone holding a printed copy. Files: `templates/gone.html` ### Done when - [ ] Editing a destination redirects new visitors correctly - [ ] A disabled link shows an explanatory page - [ ] A retired code cannot be reissued - [ ] Creation and change are recorded per link ### Watch out - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ## Not in this build - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. ## After v1, if you want it - Link expiry with a redirect to a replacement, which is kinder than a dead page - A second short domain for a different context, since one domain's reputation is shared by every link on it - Aggregate referrer reporting, which is useful and still identifies nobody ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Links and aggregated counts. Not touched by the redirect path. DATABASE_URL=postgres://user:pass@localhost:5432/links # Required. The domain serving redirects. A long-term commitment rather than a setting. SHORT_DOMAIN=exm.pl # Required. Random code length, chosen from expected volume with room for collisions to stay rare. CODE_LENGTH=7 # Required. How often the in-memory map refreshes. Also how long a destination change takes to apply. CACHE_REFRESH_SECONDS=30 # Required. Raw click rows are deleted after aggregation. Short, because they are a liability. RAW_CLICK_RETENTION_DAYS=7 # Required. Country lookup, used at request time with the address discarded afterwards. GEO_DB_PATH=./data/country.mmdb
You are building a lean indie version of Bitly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Bitly · indie build A redirect service mapping short codes to destinations with a fast redirect path, click logging that records device class and country without identifying anyone, QR codes generated for any link, and an export that lets the links survive the software. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | the redirect is the hot path and everything else is administration | | Redirect | A temporary redirect with the lookup served from memory | a redirect that waits on a database round trip is slower than the page it points at | | Codes | Generated random codes, not sequential | sequential codes let anyone enumerate every link you have ever made | | Analytics | Device class and country derived at request time, never a stored identifier | you can count clicks without building a record of who clicked | | QR | Generated at request time with error correction sized to the code | a QR code printed too small or with too little correction fails in the real world | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Go or Node.js 22 or newer** · free - Why: The redirect is a hot path worth being able to profile confidently. - Get it: go.dev or nodejs.org. - Verify: The toolchain builds and runs a hello world - [ ] **A short domain you will keep for a decade** · varies by extension; short ones cost more, and this is a long commitment - Why: This is the real prerequisite. Every link depends on the domain continuing to exist, and links outlive intentions. - Get it: Register something short you will not resent renewing. Set auto-renew immediately and record who else can renew it. - Verify: The domain auto-renews and a second person knows how - [ ] **PostgreSQL 16 or newer** · free - Why: Links and aggregated counts. The redirect path does not touch it, but everything else does. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A country lookup database you may use** · free at country level with an open database - Why: Country is derived at request time from the address, which is then discarded. The database has to be one you are licensed to use. - Get it: An openly licensed country-level database is sufficient; you do not need city precision and should not store it. - Verify: A known address resolves to the right country - [ ] **A printer, for the QR phase** · free - Why: Printed codes fail for reasons that are invisible on screen: size, quiet zone and contrast. The only test is a printed one. - Get it: Any printer. Print at the size you intend to use and scan from the intended distance. - Verify: A printed code scans at real size ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered - bundled QR code management, landing pages, and campaign grouping - city-level and device-level analytics rollups without building them - an SLA and someone to call If one of those is essential to you, that is the reason to keep paying for Bitly, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Bitly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener you run, with click analytics that do not track people to replace Bitly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A short link is a permanent commitment. Every link you create ends up in printed material, other people's documents, messages you cannot edit and pages you do not control. If the domain lapses or the service stops, every one of them breaks at once and there is no way to fix them. So decide before you shorten anything: is this domain one you will renew for a decade, and is there a plan for the links if you stop running the service? Keep a redirect map exportable as a static file, so at minimum the links can be served by something else. Know also that shorteners are treated with suspicion by spam filters and some platforms, because they hide the destination. Your own short domain has no reputation at all, which is worse than a recognised one in some contexts and better in others. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · the redirect is the hot path and everything else is administration - Redirect: A temporary redirect with the lookup served from memory · a redirect that waits on a database round trip is slower than the page it points at - Codes: Generated random codes, not sequential · sequential codes let anyone enumerate every link you have ever made - Analytics: Device class and country derived at request time, never a stored identifier · you can count clicks without building a record of who clicked - QR: Generated at request time with error correction sized to the code · a QR code printed too small or with too little correction fails in the real world ### Data model (create this before Phase 1) - links(id, code, destination, created_at, created_by, expires_at, disabled_at, title) - clicks(link_id, at, device_class, country, referrer_host) · no address, no identifier, no fingerprint - daily_counts(link_id, on_date, device_class, country, count) · aggregated, which is what the interface reads - domains(id, host, verified_at) · if you serve several short domains - The clicks table holds no value that identifies a person. That is a deliberate limit and it is what makes the analytics honest. ### Phase 1 · A redirect that is faster than the page Build: A lookup serving redirects from an in-memory map refreshed from the database, returning a temporary redirect. Measure the response time under load. Handle an unknown code with a proper not-found rather than a redirect to a home page. Trap: Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. Trap: A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. Done when: A redirect responds in single-digit milliseconds under load, measured rather than assumed. Changing a destination takes effect within the refresh interval for new visitors. An unknown code returns a not-found rather than redirecting anywhere. The redirect does not wait on writing the click. Do not build yet: No analytics, QR codes or administration. ### Phase 2 · Codes nobody can enumerate Build: Generate random codes from an alphabet excluding characters that are confused when read aloud or typed. Check for collisions on insert. Allow a custom code where you want one, with reserved words blocked. Trap: Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. Done when: Codes are random and knowing several reveals no pattern. A collision is detected on insert and retried rather than overwriting. The alphabet excludes easily confused characters, verified by reading a code aloud to someone who types it correctly. Reserved paths cannot be claimed as custom codes. Do not build yet: No analytics or QR codes yet. ### Phase 3 · Counting clicks without tracking people Build: Derive device class and country at request time and store only those with a timestamp. Store no address, no identifier and no fingerprint. Aggregate into daily counts and delete raw click rows after a short window. Trap: Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. Done when: The clicks table contains no address or identifier, verified by inspecting the schema. Country and device class are derived at request time and the input discarded. Raw clicks aggregate into daily counts and are deleted after the retention window. A click from a known address produces a country without that address appearing anywhere. Do not build yet: No QR codes or administration yet. ### Phase 4 · QR codes that work on a poster Build: Generate QR codes with error correction chosen for the use, output as SVG for print and PNG for screen, with a quiet zone. Test by printing one at its intended size and scanning it from the distance it will be scanned from. Trap: A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. Done when: A generated code scans from a phone at the intended distance after being printed at the intended size. The quiet zone is present in the output. A code with a long destination remains scannable, or the tool warns that the destination is too long for the chosen size. SVG output scales without losing sharpness. Do not build yet: No administration or export yet. ### Phase 5 · Managing links without breaking them Build: Edit a destination, set an expiry, and disable a link with a page explaining it rather than a bare error. Never delete a code in a way that lets it be reused. Record who created and last changed each link. Trap: Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. Done when: Editing a destination redirects new visitors to the new place. A disabled link shows an explanatory page rather than a bare error. A retired code cannot be reissued, verified by attempting it. Every link records who created it and when it last changed. Do not build yet: Export and operations come last. ### Phase 6 · Making the links outlive the software Build: Export the redirect map as a static file that any web server or hosting platform can serve. Back up the database. Document what happens to the links if the service stops, and set a calendar reminder for the domain renewal. Trap: The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. Done when: A static redirect map exports in a format a common host can serve, and it has been tested by actually serving it. The database backs up nightly. The domain is set to auto-renew and a reminder exists. The documentation states what happens to links if the service stops. Do not build yet: Nothing after this. ### Out of scope (and why) - The recognition of an established short domain, which cuts both ways: recognised shorteners are trusted by people and increasingly distrusted by spam filters. - Longevity. A commercial shortener's links have outlived many of the services that used them, and yours last exactly as long as you keep paying for the domain. - Campaign grouping, landing pages and the marketing features bundled into the paid tiers. - Deep click analytics with individual journeys, which this deliberately refuses in favour of counting without identifying. ### README must contain - That every short link is a permanent commitment and what happens if the domain lapses - That redirects are temporary rather than permanent, and why - That codes are random so the link list cannot be enumerated - That clicks store no address or identifier, and what is derived instead - That a retired code is never reissued ===== AGENTS.md ===== # Agent instructions · Bitly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Go or Node 22 with PostgreSQL, A temporary redirect with the lookup served from memory, Generated random codes, not sequential, Device class and country derived at request time, never a stored identifier, Generated at request time with error correction sized to the code. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ===== BUILD_PLAN.md ===== # Build plan · Bitly A redirect service mapping short codes to destinations with a fast redirect path, click logging that records device class and country without identifying anyone, QR codes generated for any link, and an export that lets the links survive the software. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A redirect that is faster than the page Following a short link adds almost nothing to the load time. ### Steps 1. Serve lookups from memory Refreshed in the background. The redirect path touches no database. Files: `src/redirect.go` 2. Use a temporary redirect So a destination change is possible. Permanent redirects are cached beyond your reach. Files: `src/redirect.go` 3. Log the click asynchronously Queued, not awaited. The visitor should not wait for your analytics. Files: `src/clicks.go` ### Done when - [ ] Redirect responds in single-digit milliseconds under load - [ ] A destination change takes effect within the refresh interval - [ ] An unknown code returns not-found - [ ] The redirect does not wait on click logging ### Watch out - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. ## Phase 2 · Codes nobody can enumerate Knowing one short code tells you nothing about the others. ### Steps 1. Generate random codes From an alphabet without confusable characters. Length chosen from your expected volume. Files: `src/codes.go` 2. Detect collisions on insert A unique constraint plus a retry. Never overwrite an existing code. Files: `migrations/` 3. Block reserved paths Your own administrative routes and anything you might want later. Files: `src/codes.go` ### Done when - [ ] Codes reveal no pattern across several links - [ ] A collision retries rather than overwriting - [ ] A code read aloud is typed back correctly - [ ] Reserved paths cannot be claimed ### Watch out - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. ## Phase 3 · Counting clicks without tracking people You learn how a link performed without building a record of who clicked it. ### Steps 1. Derive and discard Country and device class at request time. The address never reaches storage. Files: `src/clicks.go` 2. Aggregate into daily counts What the interface reads. Raw rows are transient. Files: `src/aggregate.go` 3. Delete raw rows on schedule Short window. Keeping them is a liability without a benefit once aggregated. Files: `src/retention.go` ### Done when - [ ] No address or identifier appears in the schema - [ ] Country is derived without the address being stored - [ ] Raw clicks aggregate and are deleted on schedule - [ ] Counts match a manual count for a test link ### Watch out - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. ## Phase 4 · QR codes that work on a poster A generated code scans reliably at the size it will actually be printed. ### Steps 1. Generate with a proper quiet zone Included in the output rather than assumed to be added by whoever places it. Files: `src/qr.go` 2. Choose error correction for the use Higher for print where a code may be scuffed, lower for screen where it will not. Files: `src/qr.go` 3. Print and scan at real size On paper, at the distance people will stand. Screen testing hides the failures. ### Done when - [ ] A printed code scans at its intended size and distance - [ ] The quiet zone is present in the output - [ ] A long destination warns or still scans - [ ] SVG output scales cleanly ### Watch out - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. ## Phase 5 · Managing links without breaking them You can edit and retire links without turning a working link into a dead one. ### Steps 1. Support edit, expiry and disable Three different states with three different behaviours. Files: `src/links.go` 2. Never reissue a code Retired means gone. Reuse sends people somewhere they did not intend to go. Files: `src/codes.go` 3. Explain a dead link A page saying the link has expired beats a generic error for anyone holding a printed copy. Files: `templates/gone.html` ### Done when - [ ] Editing a destination redirects new visitors correctly - [ ] A disabled link shows an explanatory page - [ ] A retired code cannot be reissued - [ ] Creation and change are recorded per link ### Watch out - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ## Not in this build - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. ## After v1, if you want it - Link expiry with a redirect to a replacement, which is kinder than a dead page - A second short domain for a different context, since one domain's reputation is shared by every link on it - Aggregate referrer reporting, which is useful and still identifies nobody ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Links and aggregated counts. Not touched by the redirect path. DATABASE_URL=postgres://user:pass@localhost:5432/links # Required. The domain serving redirects. A long-term commitment rather than a setting. SHORT_DOMAIN=exm.pl # Required. Random code length, chosen from expected volume with room for collisions to stay rare. CODE_LENGTH=7 # Required. How often the in-memory map refreshes. Also how long a destination change takes to apply. CACHE_REFRESH_SECONDS=30 # Required. Raw click rows are deleted after aggregation. Short, because they are a liability. RAW_CLICK_RETENTION_DAYS=7 # Required. Country lookup, used at request time with the address discarded afterwards. GEO_DB_PATH=./data/country.mmdb
You are building a production product version of Bitly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Bitly · product brief ## Problem A slug, a lookup, a 302, and a row in a table. Cloudflare Workers plus KV and D1 does the whole job on the free tier, and QR codes are one library call. The only thing you are really buying is that bit.ly resolves links created in 2012 and will presumably still resolve them in 2032, which is an institutional promise rather than a technical one. If the links are going in a magazine ad or on packaging, that promise is worth something. If they are going in this week's newsletter, it is worth nothing, and Growth is 420 USD a year. ## Product outcome A shortener whose redirects are faster than the pages they point at, whose links cannot be enumerated, whose analytics count without identifying, and whose links can be served by something else if you stop. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Cloudflare account (Workers, KV, D1) - a domain you control - wrangler CLI ## Explicit non-goals for v1 - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered - bundled QR code management, landing pages, and campaign grouping - city-level and device-level analytics rollups without building them - an SLA and someone to call ## Success criteria - Redirect latency is measured under load - Codes are confirmed non-enumerable - No address or identifier appears in the clicks schema - A printed QR code scans at real size - The static redirect map has been served successfully ===== BRIEF.md ===== # Build brief · Bitly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener you run, with click analytics that do not track people to replace Bitly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A short link is a permanent commitment. Every link you create ends up in printed material, other people's documents, messages you cannot edit and pages you do not control. If the domain lapses or the service stops, every one of them breaks at once and there is no way to fix them. So decide before you shorten anything: is this domain one you will renew for a decade, and is there a plan for the links if you stop running the service? Keep a redirect map exportable as a static file, so at minimum the links can be served by something else. Know also that shorteners are treated with suspicion by spam filters and some platforms, because they hide the destination. Your own short domain has no reputation at all, which is worse than a recognised one in some contexts and better in others. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · the redirect is the hot path and everything else is administration - Redirect: A temporary redirect with the lookup served from memory · a redirect that waits on a database round trip is slower than the page it points at - Codes: Generated random codes, not sequential · sequential codes let anyone enumerate every link you have ever made - Analytics: Device class and country derived at request time, never a stored identifier · you can count clicks without building a record of who clicked - QR: Generated at request time with error correction sized to the code · a QR code printed too small or with too little correction fails in the real world ### Data model (create this before Phase 1) - links(id, code, destination, created_at, created_by, expires_at, disabled_at, title) - clicks(link_id, at, device_class, country, referrer_host) · no address, no identifier, no fingerprint - daily_counts(link_id, on_date, device_class, country, count) · aggregated, which is what the interface reads - domains(id, host, verified_at) · if you serve several short domains - The clicks table holds no value that identifies a person. That is a deliberate limit and it is what makes the analytics honest. ### Phase 1 · A redirect that is faster than the page Build: A lookup serving redirects from an in-memory map refreshed from the database, returning a temporary redirect. Measure the response time under load. Handle an unknown code with a proper not-found rather than a redirect to a home page. Trap: Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. Trap: A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. Done when: A redirect responds in single-digit milliseconds under load, measured rather than assumed. Changing a destination takes effect within the refresh interval for new visitors. An unknown code returns a not-found rather than redirecting anywhere. The redirect does not wait on writing the click. Do not build yet: No analytics, QR codes or administration. ### Phase 2 · Codes nobody can enumerate Build: Generate random codes from an alphabet excluding characters that are confused when read aloud or typed. Check for collisions on insert. Allow a custom code where you want one, with reserved words blocked. Trap: Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. Done when: Codes are random and knowing several reveals no pattern. A collision is detected on insert and retried rather than overwriting. The alphabet excludes easily confused characters, verified by reading a code aloud to someone who types it correctly. Reserved paths cannot be claimed as custom codes. Do not build yet: No analytics or QR codes yet. ### Phase 3 · Counting clicks without tracking people Build: Derive device class and country at request time and store only those with a timestamp. Store no address, no identifier and no fingerprint. Aggregate into daily counts and delete raw click rows after a short window. Trap: Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. Done when: The clicks table contains no address or identifier, verified by inspecting the schema. Country and device class are derived at request time and the input discarded. Raw clicks aggregate into daily counts and are deleted after the retention window. A click from a known address produces a country without that address appearing anywhere. Do not build yet: No QR codes or administration yet. ### Phase 4 · QR codes that work on a poster Build: Generate QR codes with error correction chosen for the use, output as SVG for print and PNG for screen, with a quiet zone. Test by printing one at its intended size and scanning it from the distance it will be scanned from. Trap: A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. Done when: A generated code scans from a phone at the intended distance after being printed at the intended size. The quiet zone is present in the output. A code with a long destination remains scannable, or the tool warns that the destination is too long for the chosen size. SVG output scales without losing sharpness. Do not build yet: No administration or export yet. ### Phase 5 · Managing links without breaking them Build: Edit a destination, set an expiry, and disable a link with a page explaining it rather than a bare error. Never delete a code in a way that lets it be reused. Record who created and last changed each link. Trap: Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. Done when: Editing a destination redirects new visitors to the new place. A disabled link shows an explanatory page rather than a bare error. A retired code cannot be reissued, verified by attempting it. Every link records who created it and when it last changed. Do not build yet: Export and operations come last. ### Phase 6 · Making the links outlive the software Build: Export the redirect map as a static file that any web server or hosting platform can serve. Back up the database. Document what happens to the links if the service stops, and set a calendar reminder for the domain renewal. Trap: The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. Done when: A static redirect map exports in a format a common host can serve, and it has been tested by actually serving it. The database backs up nightly. The domain is set to auto-renew and a reminder exists. The documentation states what happens to links if the service stops. Do not build yet: Nothing after this. ### Out of scope (and why) - The recognition of an established short domain, which cuts both ways: recognised shorteners are trusted by people and increasingly distrusted by spam filters. - Longevity. A commercial shortener's links have outlived many of the services that used them, and yours last exactly as long as you keep paying for the domain. - Campaign grouping, landing pages and the marketing features bundled into the paid tiers. - Deep click analytics with individual journeys, which this deliberately refuses in favour of counting without identifying. ### README must contain - That every short link is a permanent commitment and what happens if the domain lapses - That redirects are temporary rather than permanent, and why - That codes are random so the link list cannot be enumerated - That clicks store no address or identifier, and what is derived instead - That a retired code is never reissued ===== ARCHITECTURE.md ===== # Architecture · Bitly ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | the redirect is the hot path and everything else is administration | | Redirect | A temporary redirect with the lookup served from memory | a redirect that waits on a database round trip is slower than the page it points at | | Codes | Generated random codes, not sequential | sequential codes let anyone enumerate every link you have ever made | | Analytics | Device class and country derived at request time, never a stored identifier | you can count clicks without building a record of who clicked | | QR | Generated at request time with error correction sized to the code | a QR code printed too small or with too little correction fails in the real world | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Redirect | The hot path. | Any implementation, provided it serves from memory and does not wait on logging. | | Codes | Generation and permanence. | Any random scheme. Never reissuing a retired code is the property that protects printed links. | | Clicks | Counting without identifying. | Any aggregation, provided the address is derived from and discarded rather than stored. | | QR | Codes that scan in the world. | Any library. The quiet zone and the printed test are what make them work. | | Continuity | The links outliving the software. | The static export is the escape hatch and is worth more than any feature here. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Links and aggregated counts. Not touched by the redirect path. - `SHORT_DOMAIN` · required · The domain serving redirects. A long-term commitment rather than a setting. - `CODE_LENGTH` · required · Random code length, chosen from expected volume with room for collisions to stay rare. - `CACHE_REFRESH_SECONDS` · required · How often the in-memory map refreshes. Also how long a destination change takes to apply. - `RAW_CLICK_RETENTION_DAYS` · required · Raw click rows are deleted after aggregation. Short, because they are a liability. - `GEO_DB_PATH` · required · Country lookup, used at request time with the address discarded afterwards. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Bitly product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Go or Node 22 with PostgreSQL, A temporary redirect with the lookup served from memory, Generated random codes, not sequential, Device class and country derived at request time, never a stored identifier, Generated at request time with error correction sized to the code. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. - The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. ===== MILESTONES.md ===== # Delivery milestones · Bitly Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A redirect that is faster than the page Following a short link adds almost nothing to the load time. ### Steps 1. Serve lookups from memory Refreshed in the background. The redirect path touches no database. Files: `src/redirect.go` 2. Use a temporary redirect So a destination change is possible. Permanent redirects are cached beyond your reach. Files: `src/redirect.go` 3. Log the click asynchronously Queued, not awaited. The visitor should not wait for your analytics. Files: `src/clicks.go` ### Done when - [ ] Redirect responds in single-digit milliseconds under load - [ ] A destination change takes effect within the refresh interval - [ ] An unknown code returns not-found - [ ] The redirect does not wait on click logging ### Watch out - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. ## M2 · Codes nobody can enumerate Knowing one short code tells you nothing about the others. ### Steps 1. Generate random codes From an alphabet without confusable characters. Length chosen from your expected volume. Files: `src/codes.go` 2. Detect collisions on insert A unique constraint plus a retry. Never overwrite an existing code. Files: `migrations/` 3. Block reserved paths Your own administrative routes and anything you might want later. Files: `src/codes.go` ### Done when - [ ] Codes reveal no pattern across several links - [ ] A collision retries rather than overwriting - [ ] A code read aloud is typed back correctly - [ ] Reserved paths cannot be claimed ### Watch out - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. ## M3 · Counting clicks without tracking people You learn how a link performed without building a record of who clicked it. ### Steps 1. Derive and discard Country and device class at request time. The address never reaches storage. Files: `src/clicks.go` 2. Aggregate into daily counts What the interface reads. Raw rows are transient. Files: `src/aggregate.go` 3. Delete raw rows on schedule Short window. Keeping them is a liability without a benefit once aggregated. Files: `src/retention.go` ### Done when - [ ] No address or identifier appears in the schema - [ ] Country is derived without the address being stored - [ ] Raw clicks aggregate and are deleted on schedule - [ ] Counts match a manual count for a test link ### Watch out - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. ## M4 · QR codes that work on a poster A generated code scans reliably at the size it will actually be printed. ### Steps 1. Generate with a proper quiet zone Included in the output rather than assumed to be added by whoever places it. Files: `src/qr.go` 2. Choose error correction for the use Higher for print where a code may be scuffed, lower for screen where it will not. Files: `src/qr.go` 3. Print and scan at real size On paper, at the distance people will stand. Screen testing hides the failures. ### Done when - [ ] A printed code scans at its intended size and distance - [ ] The quiet zone is present in the output - [ ] A long destination warns or still scans - [ ] SVG output scales cleanly ### Watch out - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. ## M5 · Managing links without breaking them You can edit and retire links without turning a working link into a dead one. ### Steps 1. Support edit, expiry and disable Three different states with three different behaviours. Files: `src/links.go` 2. Never reissue a code Retired means gone. Reuse sends people somewhere they did not intend to go. Files: `src/codes.go` 3. Explain a dead link A page saying the link has expired beats a generic error for anyone holding a printed copy. Files: `templates/gone.html` ### Done when - [ ] Editing a destination redirects new visitors correctly - [ ] A disabled link shows an explanatory page - [ ] A retired code cannot be reissued - [ ] Creation and change are recorded per link ### Watch out - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ## M6 · Making the links outlive the software (production only) If you stop running this, the links can still work. ### Steps 1. Export a static redirect map In a format a common static host understands. Test it by serving it. Files: `src/export.go` 2. Secure the domain Auto-renew, a reminder you will see, and a second person who knows how. Files: `docs/continuity.md` 3. Document the wind-down What happens to links if you stop. Deciding it now is easier than deciding it later. Files: `docs/continuity.md` ### Done when - [ ] The static redirect map has been served and tested - [ ] The database backs up nightly - [ ] The domain auto-renews and a reminder exists - [ ] The wind-down plan is documented ### Watch out - The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. ===== OPERATIONS.md ===== # Operations · Bitly ## Backup The links table is small and irreplaceable, since losing it breaks every link in existence. Aggregated counts are useful and replaceable. Back up nightly and keep the static export current as a second form. ## Restore Restore and follow a known link. That check, rather than the service starting, is what proves the restore, because a redirect service that runs and 404s is worse than one that is down. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Redirect latency, unknown code rate and domain expiry. A rising unknown code rate usually means someone is enumerating or a code was retired that is still in circulation. ## Incident checklist The catastrophic failure is the domain lapsing, which no amount of application reliability prevents. Auto-renew plus a human reminder plus a second person who can renew is the entire mitigation, and it is worth more than any uptime work. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Redirect latency is measured under load - [ ] Codes are confirmed non-enumerable - [ ] No address or identifier appears in the clicks schema - [ ] A printed QR code scans at real size - [ ] The static redirect map has been served successfully ## Launch constraint Do not market omitted Bitly capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Links and aggregated counts. Not touched by the redirect path. DATABASE_URL=postgres://user:pass@localhost:5432/links # Required. The domain serving redirects. A long-term commitment rather than a setting. SHORT_DOMAIN=exm.pl # Required. Random code length, chosen from expected volume with room for collisions to stay rare. CODE_LENGTH=7 # Required. How often the in-memory map refreshes. Also how long a destination change takes to apply. CACHE_REFRESH_SECONDS=30 # Required. Raw click rows are deleted after aggregation. Short, because they are a liability. RAW_CLICK_RETENTION_DAYS=7 # Required. Country lookup, used at request time with the address discarded afterwards. GEO_DB_PATH=./data/country.mmdb
# Bitly · indie build A redirect service mapping short codes to destinations with a fast redirect path, click logging that records device class and country without identifying anyone, QR codes generated for any link, and an export that lets the links survive the software. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | the redirect is the hot path and everything else is administration | | Redirect | A temporary redirect with the lookup served from memory | a redirect that waits on a database round trip is slower than the page it points at | | Codes | Generated random codes, not sequential | sequential codes let anyone enumerate every link you have ever made | | Analytics | Device class and country derived at request time, never a stored identifier | you can count clicks without building a record of who clicked | | QR | Generated at request time with error correction sized to the code | a QR code printed too small or with too little correction fails in the real world | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Go or Node.js 22 or newer** · free - Why: The redirect is a hot path worth being able to profile confidently. - Get it: go.dev or nodejs.org. - Verify: The toolchain builds and runs a hello world - [ ] **A short domain you will keep for a decade** · varies by extension; short ones cost more, and this is a long commitment - Why: This is the real prerequisite. Every link depends on the domain continuing to exist, and links outlive intentions. - Get it: Register something short you will not resent renewing. Set auto-renew immediately and record who else can renew it. - Verify: The domain auto-renews and a second person knows how - [ ] **PostgreSQL 16 or newer** · free - Why: Links and aggregated counts. The redirect path does not touch it, but everything else does. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A country lookup database you may use** · free at country level with an open database - Why: Country is derived at request time from the address, which is then discarded. The database has to be one you are licensed to use. - Get it: An openly licensed country-level database is sufficient; you do not need city precision and should not store it. - Verify: A known address resolves to the right country - [ ] **A printer, for the QR phase** · free - Why: Printed codes fail for reasons that are invisible on screen: size, quiet zone and contrast. The only test is a printed one. - Get it: Any printer. Print at the size you intend to use and scan from the intended distance. - Verify: A printed code scans at real size ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered - bundled QR code management, landing pages, and campaign grouping - city-level and device-level analytics rollups without building them - an SLA and someone to call If one of those is essential to you, that is the reason to keep paying for Bitly, and the README should say so rather than pretend.
# Build brief · Bitly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener you run, with click analytics that do not track people to replace Bitly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A short link is a permanent commitment. Every link you create ends up in printed material, other people's documents, messages you cannot edit and pages you do not control. If the domain lapses or the service stops, every one of them breaks at once and there is no way to fix them. So decide before you shorten anything: is this domain one you will renew for a decade, and is there a plan for the links if you stop running the service? Keep a redirect map exportable as a static file, so at minimum the links can be served by something else. Know also that shorteners are treated with suspicion by spam filters and some platforms, because they hide the destination. Your own short domain has no reputation at all, which is worse than a recognised one in some contexts and better in others. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · the redirect is the hot path and everything else is administration - Redirect: A temporary redirect with the lookup served from memory · a redirect that waits on a database round trip is slower than the page it points at - Codes: Generated random codes, not sequential · sequential codes let anyone enumerate every link you have ever made - Analytics: Device class and country derived at request time, never a stored identifier · you can count clicks without building a record of who clicked - QR: Generated at request time with error correction sized to the code · a QR code printed too small or with too little correction fails in the real world ### Data model (create this before Phase 1) - links(id, code, destination, created_at, created_by, expires_at, disabled_at, title) - clicks(link_id, at, device_class, country, referrer_host) · no address, no identifier, no fingerprint - daily_counts(link_id, on_date, device_class, country, count) · aggregated, which is what the interface reads - domains(id, host, verified_at) · if you serve several short domains - The clicks table holds no value that identifies a person. That is a deliberate limit and it is what makes the analytics honest. ### Phase 1 · A redirect that is faster than the page Build: A lookup serving redirects from an in-memory map refreshed from the database, returning a temporary redirect. Measure the response time under load. Handle an unknown code with a proper not-found rather than a redirect to a home page. Trap: Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. Trap: A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. Done when: A redirect responds in single-digit milliseconds under load, measured rather than assumed. Changing a destination takes effect within the refresh interval for new visitors. An unknown code returns a not-found rather than redirecting anywhere. The redirect does not wait on writing the click. Do not build yet: No analytics, QR codes or administration. ### Phase 2 · Codes nobody can enumerate Build: Generate random codes from an alphabet excluding characters that are confused when read aloud or typed. Check for collisions on insert. Allow a custom code where you want one, with reserved words blocked. Trap: Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. Done when: Codes are random and knowing several reveals no pattern. A collision is detected on insert and retried rather than overwriting. The alphabet excludes easily confused characters, verified by reading a code aloud to someone who types it correctly. Reserved paths cannot be claimed as custom codes. Do not build yet: No analytics or QR codes yet. ### Phase 3 · Counting clicks without tracking people Build: Derive device class and country at request time and store only those with a timestamp. Store no address, no identifier and no fingerprint. Aggregate into daily counts and delete raw click rows after a short window. Trap: Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. Done when: The clicks table contains no address or identifier, verified by inspecting the schema. Country and device class are derived at request time and the input discarded. Raw clicks aggregate into daily counts and are deleted after the retention window. A click from a known address produces a country without that address appearing anywhere. Do not build yet: No QR codes or administration yet. ### Phase 4 · QR codes that work on a poster Build: Generate QR codes with error correction chosen for the use, output as SVG for print and PNG for screen, with a quiet zone. Test by printing one at its intended size and scanning it from the distance it will be scanned from. Trap: A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. Done when: A generated code scans from a phone at the intended distance after being printed at the intended size. The quiet zone is present in the output. A code with a long destination remains scannable, or the tool warns that the destination is too long for the chosen size. SVG output scales without losing sharpness. Do not build yet: No administration or export yet. ### Phase 5 · Managing links without breaking them Build: Edit a destination, set an expiry, and disable a link with a page explaining it rather than a bare error. Never delete a code in a way that lets it be reused. Record who created and last changed each link. Trap: Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. Done when: Editing a destination redirects new visitors to the new place. A disabled link shows an explanatory page rather than a bare error. A retired code cannot be reissued, verified by attempting it. Every link records who created it and when it last changed. Do not build yet: Export and operations come last. ### Phase 6 · Making the links outlive the software Build: Export the redirect map as a static file that any web server or hosting platform can serve. Back up the database. Document what happens to the links if the service stops, and set a calendar reminder for the domain renewal. Trap: The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. Done when: A static redirect map exports in a format a common host can serve, and it has been tested by actually serving it. The database backs up nightly. The domain is set to auto-renew and a reminder exists. The documentation states what happens to links if the service stops. Do not build yet: Nothing after this. ### Out of scope (and why) - The recognition of an established short domain, which cuts both ways: recognised shorteners are trusted by people and increasingly distrusted by spam filters. - Longevity. A commercial shortener's links have outlived many of the services that used them, and yours last exactly as long as you keep paying for the domain. - Campaign grouping, landing pages and the marketing features bundled into the paid tiers. - Deep click analytics with individual journeys, which this deliberately refuses in favour of counting without identifying. ### README must contain - That every short link is a permanent commitment and what happens if the domain lapses - That redirects are temporary rather than permanent, and why - That codes are random so the link list cannot be enumerated - That clicks store no address or identifier, and what is derived instead - That a retired code is never reissued
# Agent instructions · Bitly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Go or Node 22 with PostgreSQL, A temporary redirect with the lookup served from memory, Generated random codes, not sequential, Device class and country derived at request time, never a stored identifier, Generated at request time with error correction sized to the code. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them.
# Build plan · Bitly A redirect service mapping short codes to destinations with a fast redirect path, click logging that records device class and country without identifying anyone, QR codes generated for any link, and an export that lets the links survive the software. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A redirect that is faster than the page Following a short link adds almost nothing to the load time. ### Steps 1. Serve lookups from memory Refreshed in the background. The redirect path touches no database. Files: `src/redirect.go` 2. Use a temporary redirect So a destination change is possible. Permanent redirects are cached beyond your reach. Files: `src/redirect.go` 3. Log the click asynchronously Queued, not awaited. The visitor should not wait for your analytics. Files: `src/clicks.go` ### Done when - [ ] Redirect responds in single-digit milliseconds under load - [ ] A destination change takes effect within the refresh interval - [ ] An unknown code returns not-found - [ ] The redirect does not wait on click logging ### Watch out - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. ## Phase 2 · Codes nobody can enumerate Knowing one short code tells you nothing about the others. ### Steps 1. Generate random codes From an alphabet without confusable characters. Length chosen from your expected volume. Files: `src/codes.go` 2. Detect collisions on insert A unique constraint plus a retry. Never overwrite an existing code. Files: `migrations/` 3. Block reserved paths Your own administrative routes and anything you might want later. Files: `src/codes.go` ### Done when - [ ] Codes reveal no pattern across several links - [ ] A collision retries rather than overwriting - [ ] A code read aloud is typed back correctly - [ ] Reserved paths cannot be claimed ### Watch out - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. ## Phase 3 · Counting clicks without tracking people You learn how a link performed without building a record of who clicked it. ### Steps 1. Derive and discard Country and device class at request time. The address never reaches storage. Files: `src/clicks.go` 2. Aggregate into daily counts What the interface reads. Raw rows are transient. Files: `src/aggregate.go` 3. Delete raw rows on schedule Short window. Keeping them is a liability without a benefit once aggregated. Files: `src/retention.go` ### Done when - [ ] No address or identifier appears in the schema - [ ] Country is derived without the address being stored - [ ] Raw clicks aggregate and are deleted on schedule - [ ] Counts match a manual count for a test link ### Watch out - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. ## Phase 4 · QR codes that work on a poster A generated code scans reliably at the size it will actually be printed. ### Steps 1. Generate with a proper quiet zone Included in the output rather than assumed to be added by whoever places it. Files: `src/qr.go` 2. Choose error correction for the use Higher for print where a code may be scuffed, lower for screen where it will not. Files: `src/qr.go` 3. Print and scan at real size On paper, at the distance people will stand. Screen testing hides the failures. ### Done when - [ ] A printed code scans at its intended size and distance - [ ] The quiet zone is present in the output - [ ] A long destination warns or still scans - [ ] SVG output scales cleanly ### Watch out - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. ## Phase 5 · Managing links without breaking them You can edit and retire links without turning a working link into a dead one. ### Steps 1. Support edit, expiry and disable Three different states with three different behaviours. Files: `src/links.go` 2. Never reissue a code Retired means gone. Reuse sends people somewhere they did not intend to go. Files: `src/codes.go` 3. Explain a dead link A page saying the link has expired beats a generic error for anyone holding a printed copy. Files: `templates/gone.html` ### Done when - [ ] Editing a destination redirects new visitors correctly - [ ] A disabled link shows an explanatory page - [ ] A retired code cannot be reissued - [ ] Creation and change are recorded per link ### Watch out - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ## Not in this build - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. ## After v1, if you want it - Link expiry with a redirect to a replacement, which is kinder than a dead page - A second short domain for a different context, since one domain's reputation is shared by every link on it - Aggregate referrer reporting, which is useful and still identifies nobody
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Links and aggregated counts. Not touched by the redirect path. DATABASE_URL=postgres://user:pass@localhost:5432/links # Required. The domain serving redirects. A long-term commitment rather than a setting. SHORT_DOMAIN=exm.pl # Required. Random code length, chosen from expected volume with room for collisions to stay rare. CODE_LENGTH=7 # Required. How often the in-memory map refreshes. Also how long a destination change takes to apply. CACHE_REFRESH_SECONDS=30 # Required. Raw click rows are deleted after aggregation. Short, because they are a liability. RAW_CLICK_RETENTION_DAYS=7 # Required. Country lookup, used at request time with the address discarded afterwards. GEO_DB_PATH=./data/country.mmdb
# Bitly · product brief ## Problem A slug, a lookup, a 302, and a row in a table. Cloudflare Workers plus KV and D1 does the whole job on the free tier, and QR codes are one library call. The only thing you are really buying is that bit.ly resolves links created in 2012 and will presumably still resolve them in 2032, which is an institutional promise rather than a technical one. If the links are going in a magazine ad or on packaging, that promise is worth something. If they are going in this week's newsletter, it is worth nothing, and Growth is 420 USD a year. ## Product outcome A shortener whose redirects are faster than the pages they point at, whose links cannot be enumerated, whose analytics count without identifying, and whose links can be served by something else if you stop. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Cloudflare account (Workers, KV, D1) - a domain you control - wrangler CLI ## Explicit non-goals for v1 - The recognition and longevity of an established short domain. - Campaign grouping and landing pages. - Individual click journeys and visitor identification. - Any guarantee your links outlive your domain renewal. - links that outlive your own infrastructure · a domain you stop renewing takes every link with it - bit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered - bundled QR code management, landing pages, and campaign grouping - city-level and device-level analytics rollups without building them - an SLA and someone to call ## Success criteria - Redirect latency is measured under load - Codes are confirmed non-enumerable - No address or identifier appears in the clicks schema - A printed QR code scans at real size - The static redirect map has been served successfully
# Build brief · Bitly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a link shortener you run, with click analytics that do not track people to replace Bitly. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero A short link is a permanent commitment. Every link you create ends up in printed material, other people's documents, messages you cannot edit and pages you do not control. If the domain lapses or the service stops, every one of them breaks at once and there is no way to fix them. So decide before you shorten anything: is this domain one you will renew for a decade, and is there a plan for the links if you stop running the service? Keep a redirect map exportable as a static file, so at minimum the links can be served by something else. Know also that shorteners are treated with suspicion by spam filters and some platforms, because they hide the destination. Your own short domain has no reputation at all, which is worse than a recognised one in some contexts and better in others. ### Stack (fixed, do not substitute) - Runtime: Go or Node 22 with PostgreSQL · the redirect is the hot path and everything else is administration - Redirect: A temporary redirect with the lookup served from memory · a redirect that waits on a database round trip is slower than the page it points at - Codes: Generated random codes, not sequential · sequential codes let anyone enumerate every link you have ever made - Analytics: Device class and country derived at request time, never a stored identifier · you can count clicks without building a record of who clicked - QR: Generated at request time with error correction sized to the code · a QR code printed too small or with too little correction fails in the real world ### Data model (create this before Phase 1) - links(id, code, destination, created_at, created_by, expires_at, disabled_at, title) - clicks(link_id, at, device_class, country, referrer_host) · no address, no identifier, no fingerprint - daily_counts(link_id, on_date, device_class, country, count) · aggregated, which is what the interface reads - domains(id, host, verified_at) · if you serve several short domains - The clicks table holds no value that identifies a person. That is a deliberate limit and it is what makes the analytics honest. ### Phase 1 · A redirect that is faster than the page Build: A lookup serving redirects from an in-memory map refreshed from the database, returning a temporary redirect. Measure the response time under load. Handle an unknown code with a proper not-found rather than a redirect to a home page. Trap: Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. Trap: A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. Done when: A redirect responds in single-digit milliseconds under load, measured rather than assumed. Changing a destination takes effect within the refresh interval for new visitors. An unknown code returns a not-found rather than redirecting anywhere. The redirect does not wait on writing the click. Do not build yet: No analytics, QR codes or administration. ### Phase 2 · Codes nobody can enumerate Build: Generate random codes from an alphabet excluding characters that are confused when read aloud or typed. Check for collisions on insert. Allow a custom code where you want one, with reserved words blocked. Trap: Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. Done when: Codes are random and knowing several reveals no pattern. A collision is detected on insert and retried rather than overwriting. The alphabet excludes easily confused characters, verified by reading a code aloud to someone who types it correctly. Reserved paths cannot be claimed as custom codes. Do not build yet: No analytics or QR codes yet. ### Phase 3 · Counting clicks without tracking people Build: Derive device class and country at request time and store only those with a timestamp. Store no address, no identifier and no fingerprint. Aggregate into daily counts and delete raw click rows after a short window. Trap: Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. Done when: The clicks table contains no address or identifier, verified by inspecting the schema. Country and device class are derived at request time and the input discarded. Raw clicks aggregate into daily counts and are deleted after the retention window. A click from a known address produces a country without that address appearing anywhere. Do not build yet: No QR codes or administration yet. ### Phase 4 · QR codes that work on a poster Build: Generate QR codes with error correction chosen for the use, output as SVG for print and PNG for screen, with a quiet zone. Test by printing one at its intended size and scanning it from the distance it will be scanned from. Trap: A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. Done when: A generated code scans from a phone at the intended distance after being printed at the intended size. The quiet zone is present in the output. A code with a long destination remains scannable, or the tool warns that the destination is too long for the chosen size. SVG output scales without losing sharpness. Do not build yet: No administration or export yet. ### Phase 5 · Managing links without breaking them Build: Edit a destination, set an expiry, and disable a link with a page explaining it rather than a bare error. Never delete a code in a way that lets it be reused. Record who created and last changed each link. Trap: Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. Done when: Editing a destination redirects new visitors to the new place. A disabled link shows an explanatory page rather than a bare error. A retired code cannot be reissued, verified by attempting it. Every link records who created it and when it last changed. Do not build yet: Export and operations come last. ### Phase 6 · Making the links outlive the software Build: Export the redirect map as a static file that any web server or hosting platform can serve. Back up the database. Document what happens to the links if the service stops, and set a calendar reminder for the domain renewal. Trap: The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it. Done when: A static redirect map exports in a format a common host can serve, and it has been tested by actually serving it. The database backs up nightly. The domain is set to auto-renew and a reminder exists. The documentation states what happens to links if the service stops. Do not build yet: Nothing after this. ### Out of scope (and why) - The recognition of an established short domain, which cuts both ways: recognised shorteners are trusted by people and increasingly distrusted by spam filters. - Longevity. A commercial shortener's links have outlived many of the services that used them, and yours last exactly as long as you keep paying for the domain. - Campaign grouping, landing pages and the marketing features bundled into the paid tiers. - Deep click analytics with individual journeys, which this deliberately refuses in favour of counting without identifying. ### README must contain - That every short link is a permanent commitment and what happens if the domain lapses - That redirects are temporary rather than permanent, and why - That codes are random so the link list cannot be enumerated - That clicks store no address or identifier, and what is derived instead - That a retired code is never reissued
# Architecture · Bitly ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Go or Node 22 with PostgreSQL | the redirect is the hot path and everything else is administration | | Redirect | A temporary redirect with the lookup served from memory | a redirect that waits on a database round trip is slower than the page it points at | | Codes | Generated random codes, not sequential | sequential codes let anyone enumerate every link you have ever made | | Analytics | Device class and country derived at request time, never a stored identifier | you can count clicks without building a record of who clicked | | QR | Generated at request time with error correction sized to the code | a QR code printed too small or with too little correction fails in the real world | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Redirect | The hot path. | Any implementation, provided it serves from memory and does not wait on logging. | | Codes | Generation and permanence. | Any random scheme. Never reissuing a retired code is the property that protects printed links. | | Clicks | Counting without identifying. | Any aggregation, provided the address is derived from and discarded rather than stored. | | QR | Codes that scan in the world. | Any library. The quiet zone and the printed test are what make them work. | | Continuity | The links outliving the software. | The static export is the escape hatch and is worth more than any feature here. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Links and aggregated counts. Not touched by the redirect path. - `SHORT_DOMAIN` · required · The domain serving redirects. A long-term commitment rather than a setting. - `CODE_LENGTH` · required · Random code length, chosen from expected volume with room for collisions to stay rare. - `CACHE_REFRESH_SECONDS` · required · How often the in-memory map refreshes. Also how long a destination change takes to apply. - `RAW_CLICK_RETENTION_DAYS` · required · Raw click rows are deleted after aggregation. Short, because they are a liability. - `GEO_DB_PATH` · required · Country lookup, used at request time with the address discarded afterwards. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Bitly product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Go or Node 22 with PostgreSQL, A temporary redirect with the lookup served from memory, Generated random codes, not sequential, Device class and country derived at request time, never a stored identifier, Generated at request time with error correction sized to the code. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. - The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it.
# Delivery milestones · Bitly Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A redirect that is faster than the page Following a short link adds almost nothing to the load time. ### Steps 1. Serve lookups from memory Refreshed in the background. The redirect path touches no database. Files: `src/redirect.go` 2. Use a temporary redirect So a destination change is possible. Permanent redirects are cached beyond your reach. Files: `src/redirect.go` 3. Log the click asynchronously Queued, not awaited. The visitor should not wait for your analytics. Files: `src/clicks.go` ### Done when - [ ] Redirect responds in single-digit milliseconds under load - [ ] A destination change takes effect within the refresh interval - [ ] An unknown code returns not-found - [ ] The redirect does not wait on click logging ### Watch out - Using a permanent redirect means browsers and intermediaries cache it forever, so a destination you later change keeps sending old visitors to the old place with no way to correct it. Use a temporary redirect unless you are certain the destination will never change. - A database query per redirect is fine at ten clicks a minute and the bottleneck at a thousand. Serve from memory and refresh in the background. ## M2 · Codes nobody can enumerate Knowing one short code tells you nothing about the others. ### Steps 1. Generate random codes From an alphabet without confusable characters. Length chosen from your expected volume. Files: `src/codes.go` 2. Detect collisions on insert A unique constraint plus a retry. Never overwrite an existing code. Files: `migrations/` 3. Block reserved paths Your own administrative routes and anything you might want later. Files: `src/codes.go` ### Done when - [ ] Codes reveal no pattern across several links - [ ] A collision retries rather than overwriting - [ ] A code read aloud is typed back correctly - [ ] Reserved paths cannot be claimed ### Watch out - Sequential or incrementing codes let anyone walk your entire link list, which for a shortener used internally exposes documents, drafts and campaigns you never published. Random codes with a collision check cost nothing and remove the problem entirely. ## M3 · Counting clicks without tracking people You learn how a link performed without building a record of who clicked it. ### Steps 1. Derive and discard Country and device class at request time. The address never reaches storage. Files: `src/clicks.go` 2. Aggregate into daily counts What the interface reads. Raw rows are transient. Files: `src/aggregate.go` 3. Delete raw rows on schedule Short window. Keeping them is a liability without a benefit once aggregated. Files: `src/retention.go` ### Done when - [ ] No address or identifier appears in the schema - [ ] Country is derived without the address being stored - [ ] Raw clicks aggregate and are deleted on schedule - [ ] Counts match a manual count for a test link ### Watch out - Storing the address to derive a country later means you have stored personal data, and once it is in the database it is in the backups and the exports. Derive at request time, store the derived value and discard the input, which gives you the same analytics with none of the obligation. ## M4 · QR codes that work on a poster A generated code scans reliably at the size it will actually be printed. ### Steps 1. Generate with a proper quiet zone Included in the output rather than assumed to be added by whoever places it. Files: `src/qr.go` 2. Choose error correction for the use Higher for print where a code may be scuffed, lower for screen where it will not. Files: `src/qr.go` 3. Print and scan at real size On paper, at the distance people will stand. Screen testing hides the failures. ### Done when - [ ] A printed code scans at its intended size and distance - [ ] The quiet zone is present in the output - [ ] A long destination warns or still scans - [ ] SVG output scales cleanly ### Watch out - A QR code without an adequate quiet zone around it, or printed smaller than its module size supports, fails to scan and the failure is silent: people simply do not scan it and you never hear. Keep the quiet zone, and test a printed code at real size rather than on screen. ## M5 · Managing links without breaking them You can edit and retire links without turning a working link into a dead one. ### Steps 1. Support edit, expiry and disable Three different states with three different behaviours. Files: `src/links.go` 2. Never reissue a code Retired means gone. Reuse sends people somewhere they did not intend to go. Files: `src/codes.go` 3. Explain a dead link A page saying the link has expired beats a generic error for anyone holding a printed copy. Files: `templates/gone.html` ### Done when - [ ] Editing a destination redirects new visitors correctly - [ ] A disabled link shows an explanatory page - [ ] A retired code cannot be reissued - [ ] Creation and change are recorded per link ### Watch out - Deleting a link and later issuing the same code to a different destination sends people who followed an old link somewhere unexpected, which for a link in printed material is genuinely misleading. Retire codes permanently rather than freeing them. ## M6 · Making the links outlive the software (production only) If you stop running this, the links can still work. ### Steps 1. Export a static redirect map In a format a common static host understands. Test it by serving it. Files: `src/export.go` 2. Secure the domain Auto-renew, a reminder you will see, and a second person who knows how. Files: `docs/continuity.md` 3. Document the wind-down What happens to links if you stop. Deciding it now is easier than deciding it later. Files: `docs/continuity.md` ### Done when - [ ] The static redirect map has been served and tested - [ ] The database backs up nightly - [ ] The domain auto-renews and a reminder exists - [ ] The wind-down plan is documented ### Watch out - The domain lapsing is the failure that breaks every link at once and it happens through a missed renewal email rather than a decision. Set the domain to auto-renew, put a reminder in a calendar you actually read, and record who else knows how to renew it.
# Operations · Bitly ## Backup The links table is small and irreplaceable, since losing it breaks every link in existence. Aggregated counts are useful and replaceable. Back up nightly and keep the static export current as a second form. ## Restore Restore and follow a known link. That check, rather than the service starting, is what proves the restore, because a redirect service that runs and 404s is worse than one that is down. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Redirect latency, unknown code rate and domain expiry. A rising unknown code rate usually means someone is enumerating or a code was retired that is still in circulation. ## Incident checklist The catastrophic failure is the domain lapsing, which no amount of application reliability prevents. Auto-renew plus a human reminder plus a second person who can renew is the entire mitigation, and it is worth more than any uptime work. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Redirect latency is measured under load - [ ] Codes are confirmed non-enumerable - [ ] No address or identifier appears in the clicks schema - [ ] A printed QR code scans at real size - [ ] The static redirect map has been served successfully ## Launch constraint Do not market omitted Bitly capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Links and aggregated counts. Not touched by the redirect path. DATABASE_URL=postgres://user:pass@localhost:5432/links # Required. The domain serving redirects. A long-term commitment rather than a setting. SHORT_DOMAIN=exm.pl # Required. Random code length, chosen from expected volume with room for collisions to stay rare. CODE_LENGTH=7 # Required. How often the in-memory map refreshes. Also how long a destination change takes to apply. CACHE_REFRESH_SECONDS=30 # Required. Raw click rows are deleted after aggregation. Short, because they are a liability. RAW_CLICK_RETENTION_DAYS=7 # Required. Country lookup, used at request time with the address discarded afterwards. GEO_DB_PATH=./data/country.mmdb
$ choose a build depth, inspect the files, then open the complete pack in your agent
Bitly's actual product is a promise about the future: that a link printed on a box in 2019 still resolves today. No amount of code buys that, only an institution that intends to keep existing. Teams also pay for shared workspaces, SSO, and the audit trail, and marketing departments pay because the alternative is asking engineering for a redirect and waiting two weeks.
xlinks that outlive your own infrastructure · a domain you stop renewing takes every link with it
xbit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered
xbundled QR code management, landing pages, and campaign grouping
xcity-level and device-level analytics rollups without building them
xan SLA and someone to call
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Bitly →· no votes, no pay-to-list · just what's real
Bitly pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 5 links/month; 2 QR codes/month; 2 landing pages; 3 custom back-halves/month; 1,000 API requests/month; unlimited clicks/scans |
| core | — | $10/workspace | 100 links/month; 5 QR codes/month; 5 landing pages; 5 redirects/month; 5,000 API requests/month; 30-day analytics history |
| growth | $35/workspace | $29/workspace | 500 links/month; 10 QR codes/month; 10 landing pages; 20 redirects/month; 25,000 API requests/month; 120-day history; 1 custom domain |
| premium | $300/workspace | $199/workspace | 3,000 links/month; 200 QR codes/month; 20 landing pages; 110 redirects/month; 50,000 API requests/month; 1-year history |
| enterprise | custom | — | Custom links, QR codes, landing pages, API volume and users; 2-year analytics history |
free tier5 links/month, 2 QR codes/month, 2 landing pages, 3 custom back-halves/month, 1,000 API requests/month, and unlimited clicks/scans
billingCore annual only; Growth and Premium monthly + annual; plans auto-renew and sales tax may apply
hidden costsCore cannot be bought monthly. Feature quotas reset monthly; extra enterprise capabilities such as parameter passing can be separately purchasable, and sales tax may be added.
verified 2026-08-12 · source ↗
Vibecode Bitly
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Bitly replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Bitly cost?
Bitly costs about $35/month (Growth, checked 2026-07-30), which is $420 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Bitly?
Honestly: links that outlive your own infrastructure · a domain you stop renewing takes every link with it; bit.ly's recognition in the link itself, which cuts both ways now that shorteners get spam-filtered; bundled QR code management, landing pages, and campaign grouping; city-level and device-level analytics rollups without building them; an SLA and someone to call. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Bitly?
Yes: Dub (Bitly with deeper attribution; self-hosting means recreating half a startup's infrastructure.) Sink (A polished Bitly clone on Cloudflare; free hosting, several bindings, and no server to patch.) Shlink (Branded links, routing rules and serious click stats in one container; the QR endpoint is living on borrowed time.) All 4 curated free alternatives are at vibecodeit.com/bitly/alternatives. The prompt is for when you want it exactly your way.