How I Built a Tiny URL Shortener with Next.js and Firebase

A personal build story: why devy.in needed sharable links, a deliberately simple architecture on Next.js App Router and Firestore, how the public REST API behaves, and how per-IP rate limits keep abuse away without turning the product into account sprawl.

Posted On: Sunday, 10-May-2026
How I Built a Tiny URL Shortener with Next.js and Firebase

This story starts where a lot of good engineer stories start, not with a pitch deck, but with a very mundane annoyance. I needed sharable links for long articles on this site. The kind of link you paste into a slide, a footnote, a thread, or a QR code, without feeling like you are dragging a garden hose through a keyhole. I looked around for free options that felt reliable and simple. Many tools were one of those adjective pairs where you only get one. Reliable but busy. Simple but flaky. Free but chatty with trackers. So I did what you probably do when the market underwhelms you: I built something embarrassingly small on purpose, then opened it up because devy.in is already a short hostname and I figured you should not have to repeat my late night comparison shopping.

That is the honest spine of the product you see on the URL shortener page. The rest of this post is the systems sketch: Next.js on the surface, Firebase underneath, three friendly doors in, one small room for data, and rate limits that protect the bill without turning the thing into Yet Another Account Wall.

The architecture I wanted: boring on purpose

If you diagram this like a conference talk, you might draw microservices, a gateway, a cache tier, and a fleet of workers. Lovely. I did not want that life for a side utility that began as "make my posts easier to share". I wanted a single application boundary that I understand while half awake, and a database that behaves like a good notebook: append rows, read by key, move on.

Concretely, three different humans or scripts can show up at the front door, and they all meet the same mental model.

First, the browser form. This is the polite path. A person types a URL, the UI does the usual UX niceties, and on the server I treat bots the way a museum treats muddy boots. More on that in a moment.

Second, the programmatic REST API. This is for demo apps, hobby scripts, and that one automation you swear you will rewrite later. It speaks plain JSON and understands a GET for one link or a POST for a small batch.

Third, the redirect path. Someone clicks https://devy.in/o/{token} and the app should do exactly what a short link promises, resolve the token, then go away and let the browser continue to the real destination.

Under all of that sits one Firestore collection that stores the long URL, a short random token, and enough timestamps to reason about freshness later. Not two databases pretending to be one. Not a separate analytics warehouse pretending it is innocent.

Play with the diagram if you like. It is interactive because words are great, but sometimes arrows help your brain cache the story faster than five paragraphs of zeal.

How creation works, without pasting my repo into your brain

When a new long URL shows up, the service does something that looks obvious in hindsight and is still easy to get wrong if you skip the boring parts.

The happiest path is to check whether this exact long URL already has a token. If yes, return the existing pair. That gives you idempotence for free in the human sense: spamming the button does not spam new rows. If no, mint a random token, make sure it is not already taken, then write a small document with the URL, the token, and timestamps. If you are thinking "retry on collision", you are reading my mind. Random space is large; humility is larger.

Here is pseudocode that captures the spirit without pretending to be a copy paste job.

function rememberLongUrl(longUrl, store):
  existing = store.findByLongUrl(longUrl)
  if existing exists:
    return existing.shortLink

  token = randomToken()
  while store.findByToken(token) exists:
    token = randomToken()

  record = { token, longUrl, createdAt: now(), lastTouchedAt: now() }
  store.insert(record)
  return composeShortLink(record.token)

For batch callers, the idea stays the same even when the array is longer than one. You de-duplicate within a request because nobody enjoys paying for twelve identical rows with twelve cute tokens.

The public API shape, in plain language

The REST surface is intentionally childlike: a GET with a query parameter for the long URL, or a POST with a JSON body that carries an array of URLs. The response is either a single object or a list of pairs, each with the original and the shortened form. Errors are the classics you would expect if you have ever played with a civilized HTTP API: missing input, payload too large, rate limit exhausted.

Server side, I validate early and fail loud. Not every project needs that posture, but a free utility on the public internet that wants to stay free benefits from being a little strict at the door.

onHttpRequest(incoming):
  if incoming.method is GET:
    longUrl = incoming.query.url
  else if incoming.method is POST:
    longUrlList = incoming.json.urls

  if longUrl / longUrlList is missing:
    return 400 with a human readable hint

  if payload violates max length or batch size:
    return 413 with a boring, accurate message

  if rateLimiter says no for this caller identity:
    return 429 and ask them to breathe

  return createOrReuseShortLinks(...)

Again, behavior over bytes. Your integration should cache results if you plan to call the shorten endpoint inside a tight loop. The service loves you more when you act like a good neighbor.

Rate limiting without turning the project into enterprise cosplay

I will not pretend a hobby utility invents distributed systems from scratch. The philosophy I chose is defensive minimalism: track a handful of counters keyed off the caller's best available network identity, reset them on a windowed cadence, and give GET and POST different allowances so a polite script does not inherit the same budget as an angry scraper.

The diagram tells the emotional version of the story: every call meets a small gate before it touches the expensive stuff.

If you ship on a platform that can scale out to many instances, an in-memory limiter is honest about its limitations. It is not magically global. It is still surprisingly effective at stopping accidental stampedes and casual abuse without adding Redis homework on day one. When you outgrow it, you upgrade the mechanism, not the user experience philosophy.

function allowOrDeny(incoming, policy):
  key = deriveClientKeyFromTrustedHeaders(incoming)
  bucket = counters[key]

  if bucket.windowExpired(policy.windowMillis):
    bucket.reset(now())

  if bucket.count >= policy.maxHits:
    return DENY

  bucket.increment()
  return ALLOW

If you ever wondered why your perfect script got a 429, picture a friendly bouncer who only knows about the last few seconds of crowd density. Bring a backoff, maybe some jitter, and a smile.

Why the browser path gets extra friction and the API does not

This is product judgment, not mathematics. A human in a form can solve a lightweight proof-of-humanity puzzle. That cuts basic bot traffic without annoying your integrations. Scripts and tools get the REST path with stricter numeric limits instead, because developers can handle a sleep statement when they hit a limit, while humans hate surprise puzzles inside JSON parsers.

If that feels inconsistent, good. Consistency is not the goal. Appropriate friction is.

Policies I say out loud because trust is an API too

The marketing page spells out the social contract: no ads, no tracking theater, free tier for real humans, and a few guardrails that keep the database from becoming landfill. Rate limits exist because abuse is real. Retention rules exist because cold links nobody visits should not live forever in someone else's liability column. If you want the cheerful FAQ version of the same promises, it is on the product page, written in the same voice as this essay.

What I would tell my past self at the end

If you only remember three things from this ramble, make them these. Keep the write path idempotent where you can, because humans double click. Keep the read path rude in a good way: resolve and redirect, nothing fancy. Keep limits compassionate but numeric, because the worst failures of free tools are quiet bankruptcy, not a crisp 429.

ANAbhilash Nayak
Last Updated on: 10-05-2026