I Vibe-Coded a Shields.io Clone - Then I Wouldn't Ship It

I Vibe-Coded a Shields.io Clone - Then I Wouldn't Ship It
I Vibe-Coded a Shields.io Clone - Then I Wouldn't Ship It

I asked the AI for a simple Shields.io clone in FastAPI. Twenty minutes later I had a green badge in the browser.

Left side grey: build. Right side green: passing.

Same silhouette you paste into a README without thinking. For a second, it felt almost finished, the kind of finished that makes you open a repo and type README.md.

The example in this article is simplistic by nature, but the principles are valid for a more complex AI build.

Claude Code, Cursor or Codex can, at this point in time build a very accurate clone for most applications, but that doesn't mean that it is ready for you to put that URL on the public internet, and that gap is why I wrote this down.


The bet

I kept the surface small on purpose. Flat badges only: a URL like /badge/{label}-{message}-{color}, one FastAPI route, SVG out. No style variants, no logo endpoints, no shields.io feature zoo. If the path looked like build-passing-green, the response should look like the badge everyone already recognizes.

I also told the model to vibe-code the first version: prompt hard, accept the first pass that renders, harden after. Not “design a careful architecture”. Roughly what I pasted:

FastAPI app: GET /badge/{label}-{message}-{color} returns a flat
Shields-style SVG. Keep it tiny. Fake the widths if you have to.

I wanted the thing that shows up in twenty minutes when you describe the screenshot and hit enter, because that’s what most of these clones actually are, even when the write-up pretends otherwise.

What I was not testing: deploy, auth, multi-region anything. Narrower question: can an AI give you a demo that looks done, and can you still name what you’d refuse to expose before anyone else’s README depends on it?


What “done” looked like

The happy path was almost rude in how little it needed. One route. Split the trailing path on -. Map a handful of color names. Stuff label and message into an SVG string. Return it with the right content type so the browser renders a badge instead of XML soup.

from fastapi import FastAPI, Response

app = FastAPI()

COLORS = {
    "green": "#4c1",
    "brightgreen": "#4c1",
    "red": "#e05d44",
    "blue": "#007ec6",
    "grey": "#555",
    "gray": "#555",
}

@app.get("/badge/{badge_path}")
def badge(badge_path: str) -> Response:
    label, message, color = badge_path.rsplit("-", 2)
    fill = COLORS.get(color, "#555")
    # Fake widths — good enough for build-passing-green, wrong for longer text
    left_w, right_w = 50, 60
    width = left_w + right_w
    svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="20">
  <rect width="{left_w}" height="20" fill="#555"/>
  <rect x="{left_w}" width="{right_w}" height="20" fill="{fill}"/>
  <text x="{left_w // 2}" y="14" fill="#fff" font-size="11"
        text-anchor="middle" font-family="DejaVu Sans,Verdana,sans-serif">{label}</text>
  <text x="{left_w + right_w // 2}" y="14" fill="#fff" font-size="11"
        text-anchor="middle" font-family="DejaVu Sans,Verdana,sans-serif">{message}</text>
</svg>'''
    return Response(content=svg, media_type="image/svg+xml")

Hit /badge/build-passing-green. Green and satisfying, the kind of result that makes vibe-coding feel like cheating in a good way.

That rsplit("-", 2) and those hardcoded widths are doing a lot of silent lying. They work until they don’t.


Where the demo lies

The badge looked right because I tested the one URL everyone tests. Production is the other URLs.

1. The path is not a triple

rsplit("-", 2) feels clever until a field itself contains a hyphen. You meant label build, message pre-release, color orange. The path is /badge/build-pre-release-orange. The stub returns:

label="build-pre"  message="release"  color="orange"

Wrong badge, still a 200. Same class of pain: coverage-95% (that % wants encoding) and anything else a README will mangle. In the browser you typed a clean string. In a README, someone will paste chaos.

Shields.io escapes inside the path so hyphens stay data, not delimiters: _ for a space, -- for a literal -. So pre-release becomes pre--release, and /badge/build-pre--release-orange still splits into three fields. My stub has none of that.

Before I’d expose it: define the split rules (and the failures) in tests. A demo that only accepts build-passing-green is a screenshot generator.

2. Colors are a tiny language

green and brightgreen both mapped to #4c1 in my stub. Fine for a demo. Real traffic sends brightgreen, #4c1, 4c1, and typos. Unknown names silently becoming grey is a nice default until a CI badge says “passing” in the color of “who knows”.

Before I’d expose it: one explicit color table, hex parsing with a hard reject, and no “close enough” fallbacks that lie about status.

3. Width is the tell

Hardcoded 50 and 60 make short English labels look professional. Hit /badge/continuous-integration-passing-green and the stub still draws a 50px grey rect. At 11px DejaVu, continuous-integration wants roughly 140px. The letters run out of the pill; the green side stays pinned at 60. Looks broken in the browser, still a 200.

Real badge services measure text. My stub skipped that.

Minimum I’d demand: measure (or approximate) label/message width and size the rects from that. If you’re still faking widths, say so in the README. Don’t imply parity with shields.io.

4. Caching is part of the contract

A raw FastAPI Response with an SVG body will get hit on every README render unless you say otherwise. No Cache-Control, no ETag, and you’ve built a chatty origin for other people’s traffic patterns.

Ship gate: decide what “the badge changed” means, then set headers on purpose. For a static flat badge I’d start here:

return Response(
    content=svg,
    media_type="image/svg+xml",
    headers={"Cache-Control": "public, max-age=60"},
)

Sixty seconds is arbitrary. Leaving the header off is a decision too, usually a bad one.

5. Abuse shows up before features do

Hotlinking is the product. That’s the point of a badge URL. Without rate limits, size caps, or a boring allowlist of what you’ll render, you’re volunteering as free image hosting for the internet’s worst README experiments. Add remote logos later and you’ve bought an SSRF-shaped problem (your server fetching a URL an attacker chose) while chasing a shiny /logo/ route.

Before I’d expose it: cap path length, reject junk early, rate-limit by IP at minimum, and don’t fetch anything remote until you have a reason stronger than “shields.io has logos”.


Would I ship it?

I would not ship this version as a public URL that other people’s READMEs depend on.

I would show it in a terminal, keep it on localhost, and use it to argue with AI about path parsing and text width. I would not paste https://my-box/badge/build-passing-green into a repo that strangers clone.

Vibe-coding still did its job. The model got me a convincing SVG faster than I would have typed it. I started asking harder questions when the badge looked done: what did I still not trust?

If you are evaluating whether AI ships useful things or only demos, steal the harden list into your next review. Treat the green rectangle as a prototype receipt.

Twenty minutes for the SVG. The rest is the list above.


When I’d do this again

I’d do it again when the browser answers in seconds and the whole thing fits in one route. Badges. Small SVG toys. A JSON endpoint that either returns or doesn’t.

If “it rendered” doesn’t teach you something immediately, pick a different toy. What I’d repeat is writing the harden list while the demo still feels finished, the list you’d actually defend in review.

Full source code: shields-vibe-clone.


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice/

Follow me on YouTube: https://www.youtube.com/@DevAsService

Nuno Bispo

Nuno Bispo

Solutions Architect · Senior Python & AI Engineer · AI Audits · Helping teams fix what they shipped too fast
Netherlands