Build your own link shortener step by step

Build your own link shortener step by step with slugs, redirects, and a click count. Follow the prompts, check each step, and skip Bitly's $35–$300 platform.

  • guide
  • vibe-coding
  • bitly

You can build your own link shortener step by step as slugs, redirects, and a click count. Bitly is that product. The catalog line is “Short links with names you can remember and numbers you can trust.”

You pay $35–$300 a month. That is $420–$3,600 a year. The verdict is “Tiny by definition”. The first estimate is 90 minutes: short links, redirects, click counts.

I already wrote the teardown, Build your own link shortener. This guide is the walk. Prompts in order, then what to look at.

The audience is a marketing team managing campaign links on its own domain. If you need deep links into mobile apps, or a permission matrix for a company, keep paying. The same v1 also replaces Dub, Rebrandly, and Short.io.

The Bitly page splits the work into three rows.

The first row is the one you want. A slug you pick or generate. A destination you can edit. A number that goes up when someone hits the URL. That is the 90-minute v1.

The second row is 5 hours. Custom slugs, campaign tags, QR codes, expiry. Do that after you have sent a real link.

The third row is a week. Global analytics, deep links, enterprise permissions. Skip it.

If you tell an agent to “clone Bitly”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 90 minutes.

const link = {
  domain: 'go.flaviocopes.com',
  slug: 'js-course',
  destination: 'https://flaviocopes.com/javascript/',
  enabled: true,
  clicksToday: 0,
}

A visitor hits https://go.flaviocopes.com/js-course and lands on the current destination. You change the destination later. The tweet still works.

Campaign waits. ClickEvent can be a (link_id, day) counter. The identity is the slug record, so totals stay when the destination changes.

Two tables cover the 90-minute row.

const shortLink = {
  id: 'link-12',
  domain: 'go.flaviocopes.com',
  slug: 'js-course',
  destination: 'https://flaviocopes.com/javascript/?utm_source=newsletter',
  enabled: true,
  createdAt: '2026-10-23T09:12:00.000Z',
}

const clickDay = {
  linkId: 'link-12',
  day: '2026-10-23',
  count: 2,
}

short_links needs a unique index on (domain, slug). click_days needs a unique index on (link_id, day) so two Workers incrementing the same morning do not invent a second row.

SAVE THE LINK go.flaviocopes.com / js-course Points at flaviocopes.com/javascript/ VALIDATE THE URL http or https only, no credentials Never fetch the destination WRITE ONE SLUG Unique on domain plus slug JS-course collides with js-course REDIRECT AND COUNT 302, then a (link, day) increment
Save, validate, lock the slug. The visitor gets a 302 and you get a count.

Pick the stack you already write

Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for Bitly is Workers, D1, and Astro.

This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language.

Open Build your own Bitly. The guardrail is the useful sentence: block dangerous protocols, rate-limit redirect creation, and minimize stored request data. The non-goal sits next to it. No ad retargeting, deep links, link-in-bio pages, or enterprise permissions.

Pick one stack and stay on it.

Prompt 1. Scaffold Shortstack and stop

Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 90-minute row only. Short links, redirects, click counts.

Project name: Shortstack.
Owner is one person. Visitors do not create accounts.

Do not add campaign tags, QR codes, or expiry yet.
Do not add bulk CSV import or a date-range chart.
Do not add deep links, a bio page, or roles.

When the plan is done, stop and wait. Do not write
implementation code until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /dashboard for the owner list
  • /links/new for the editor
  • /:slug for the public redirect

You want a short_links table. A click_days table with (link_id, day, count) is enough. campaigns should not appear.

If the plan includes OAuth for visitors, Stripe, or a users table for people who click, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as the owner. You should see an empty dashboard and a create action.

Open the migrations. short_links.slug is unique per domain. There is a unique constraint, not a comment that says “check first”. If the README is a placeholder, stop and make the agent write a real one before you add logic.

Now fill the editor. The mini-prompt on the product page is “The URL validator that refuses politely”. Use a tighter version so the agent does not fetch the destination.

Implement link create and edit for Shortstack.

An owner can save domain, slug, destination, and enabled.
Domain is go.flaviocopes.com in v1. Slug is optional. If the
owner leaves it blank, generate one later. Destination is
required.

Accept only http and https. If the paste has no scheme,
assume https. Reject javascript:, data:, file:, protocol-
relative tricks, embedded credentials, control characters,
and newlines. Parse with the language URL parser, not a
regex. Never fetch the destination.

On redirect, pass the destination through untouched so UTM
tags stay byte-for-byte. Rate-limit creation at 30 new
links an hour per account.

A failed save keeps the rest of the draft. Do not wipe the
slug because the destination is missing a slash.

Seed one link:
- domain go.flaviocopes.com
- slug js-course
- destination https://flaviocopes.com/javascript/?utm_source=newsletter
- enabled true

Done when I can save those fields, refresh, and see the
same values. Do not redirect visitors yet.

Agents invent URL fields that accept anything. Your domain then becomes an open redirect with a short slug on it.

function normalizeDestination(raw) {
  const trimmed = raw.trim()
  const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`
  const url = new URL(withScheme)

  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error('Only http and https destinations are allowed')
  }

  if (url.username !== '' || url.password !== '') {
    throw new Error('Destinations cannot include credentials')
  }

  return url.href
}

normalizeDestination('flaviocopes.com/javascript/?utm_source=newsletter')
//https://flaviocopes.com/javascript/?utm_source=newsletter

You are validating shape, not liveness. Fetching turns the shortener into an SSRF proxy and slows every save.

What to check after the destination saves

Create js-course. Refresh. Destination still has utm_source=newsletter.

Paste javascript:alert(1) and get a clear error. Paste https://user:[email protected] and get the credentials error. Paste flaviocopes.com/javascript/ and see https:// added.

If the agent adds a “check that the page loads” button that hits the destination from the server, delete that function.

Prompt 3. Make the slug unique for real

The second mini-prompt is “Two people, one slug, exactly one winner”. Paste this after the editor saves.

Build slug management for Shortstack on go.flaviocopes.com.

Normalize every slug: trim, lowercase for comparison.
Uniqueness is domain plus slug. Enforce it in the creation
transaction with a unique constraint, not a check-then-
insert. Two simultaneous creates for launch produce one
link and one collision error that suggests two alternatives.

Reserve dashboard, api, health, and links. A user must not
shadow the admin page. Case variants of an existing slug
are collisions. JS-course is taken if js-course exists.

Generated slugs use this alphabet, length 7:
abcdefghijkmnopqrstuvwxyz23456789
Skip 0, O, l, and 1. On a random collision, generate again
instead of failing the request.

Done when concurrent creates for launch yield one row, a
reserved slug is refused, and a blank slug still saves.

Two people hitting create on launch at the same time is the failure agents miss. The unique constraint is the last line of defense.

const SLUG_ALPHABET = 'abcdefghijkmnopqrstuvwxyz23456789'

function generateSlug(length = 7) {
  let slug = ''
  for (let i = 0; i < length; i++) {
    slug += SLUG_ALPHABET[Math.floor(Math.random() * SLUG_ALPHABET.length)]
  }
  return slug
}

generateSlug()
//k4n7p2m

Keep generation in one function. The same function serves a blank slug field. Bulk CSV, later, should call it too.

What to check after the slug lock

Create launch. Try Launch in another tab. You get a collision, not a second row.

Try slug dashboard. The form refuses it. Create a link with an empty slug. You get a 7-character code with no 0, O, l, or 1.

If two rows share launch, the unique constraint is missing. Add it before you decorate the dashboard.

Prompt 4. Redirect the visitor and count the click

Now the public path. The product page also has “Click analytics with amnesia”. Stay on the 90-minute half of that idea.

Build the public redirect for /:slug on go.flaviocopes.com.

Look up the enabled link. Return 302 to the stored
destination, not 301. A 301 gets stuck in the browser
after you edit the destination. Preserve the destination
query string exactly. Do not append extra parameters.

After the visitor is already gone, increment a click_days
row keyed on (link_id, UTC day). Filter obvious bots by
user agent before counting. Never persist full IPs, raw
user agents, or query strings from the referrer.

Disabled links show a branded unavailable page and do not
increment anything. No country, device, or referrer store
in v1.

Seed two clicks on js-course for 2026-10-23. The dashboard
total for that link is 2 after a refresh.

Done when GET /js-course returns 302 to
https://flaviocopes.com/javascript/?utm_source=newsletter
and the count moves when I click it from a phone.

Tonight you skip country and device. Increment a (link_id, day) row.

function clickKey(linkId, requestedAt) {
  const day = requestedAt.toISOString().slice(0, 10)
  return `${linkId}:${day}`
}

clickKey('js-course', new Date('2026-10-23T18:04:00.000Z'))
//js-course:2026-10-23

Write the increment after the 302 is on the way. A slow analytics insert should not hold the visitor.

If you cache the destination, invalidate that cache in the same write that edits or disables the link. Historical counts stay on the slug through every destination change.

What to check after the redirect

Open https://go.flaviocopes.com/js-course in a private window. You land on the JavaScript course with the UTM still there. Refresh /dashboard. The total went up.

Disable the link. Hit the short URL again. You get the unavailable page. The count did not move.

Edit the destination to https://flaviocopes.com/. Hit the short URL. You land on the homepage. The click total is still the same slug, not a new row.

If the 302 strips utm_source, the handler is rebuilding the URL. Pass destination through.

If the response is 301, change it. Edit the destination once and hit the short URL from the same browser. A cached 301 will ignore the new target.

Failures the agent ships anyway

An open redirect with a pretty slug

javascript: and data: have to die at save time. A regex that looks for http still lets http:// sit in front of a newline and a second URL. Use the parser.

Fetching the destination

A health check that HEADs the target from your Worker is SSRF. You do not need to know the page is up. The tweet still works if the site is down for ten minutes.

Check then insert

Two creates for launch both read “free” and both write. The unique constraint on (domain, slug) is the lock. The collision error should name two free alternatives, not dump a SQL string.

Counting people

Full IP, raw user agent, and referrer query strings are how a shortener becomes a surveillance file. The catalog is explicit. Keep the short link, the day, and a number.

A 301 that outlives the edit

The product is an editable redirect. 302 Found keeps the lookup on your Worker. 301 Moved Permanently lets Chrome remember js-course as the old course URL after you point it at a new post.

Guest accounts

Visitors do not sign in. Magic links for people who click are a bio page in disguise. A page of links is Linktree, $9–$24 a month, about 30 minutes. You already have that post.

Deploy the short URL

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Shortstack wants Workers, D1, and Astro. Put it on a subdomain you already own, go.flaviocopes.com. The AHA prompt points at Cloudflare D1 for the tables and Workers for the redirect.

Attach the custom domain on the Worker. The short URL is the product, so go.flaviocopes.com/js-course has to resolve on the public internet, not only on localhost.

Ship with the seed js-course link. Click it from a phone on cellular, not the same Wi-Fi as your laptop. Confirm the dashboard count moves after a refresh. Point one real campaign at it for a week.

If you miss expiry or a QR download, those are the 5-hour row and the last two mini-prompts on the card. Add them after you print something.

Take a D1 backup, then cancel Bitly, or Dub, or Rebrandly, or Short.io, whichever one you pay for.

What to skip on purpose

Leave out ad retargeting, deep links, a link-in-bio page, and anything that looks like enterprise permissions.

90-MINUTE V1 Custom or generated slugs Safe http and https redirects A daily click count per link SKIP THIS HALF Ad retargeting and deep links A link-in-bio page Enterprise permissions
Ship the 90-minute shortener. Leave retargeting, deep links, and roles on the skip row.

A QR code that stays editable after you print it is QR Tiger, $15–$49 a month, about 90 minutes. The QR Tiger walkthrough is that post. The 5-hour Bitly row already includes a QR download. Build the squares if the poster is the rent.

A booking page is a different job. That is the Calendly guide. Weekly hours and a slot lock, not a slug.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Bitly. Copy the AHA prompt, or the stack you already write.

Tell the agent the first estimate only: short links, redirects, click counts. Ninety minutes.

Publish go.flaviocopes.com. Point one real campaign at it this week.