For a while my GitHub profile had a small pile of stats cards on it. One for streaks, one for top languages, one for repo counts. Each came from a different service, each looked slightly different, and none of them could be told to show exactly what I wanted. If I needed a compact card for a project README and a fuller one for my profile page, I was out of luck — the tools gave me one shape and that was that.
So I built statsvg_rs: a single Rust program that renders GitHub stats cards as SVG, with enough knobs that one tool can produce a header-heavy profile card and a stripped-down lifetime-stats card from the same code.
What it actually makes
There are two presets, and the difference is the whole point.
profile.svg is the card you drop into a project README, where the reader doesn’t know who you are yet. It leans on identity: avatar, bio, location, a row of last-year stats, a contribution grid, top languages, and pinned repos.
stats.svg is what I call the anti-profile. It’s meant for your profile README — the username/username repo — where your avatar and bio are already sitting right above it. So it drops all of that and instead shows the things GitHub tends to hide: lifetime contributions since you joined, your longest streak, all-time stars and commits, the top repos you’ve contributed to but don’t own, and your single most-starred project as a highlight.
Both are driven by the same flags. Width, theme (github_dark, nord, dracula, light, solarized), which sections to show or hide, how many pinned repos, an optional highlight line. If you want a profile card with no contribution grid, that’s one flag. If you want the lifetime variant but with the header back on, that’s one flag too. That flexibility is the feature I couldn’t find anywhere else.
How it works
The flow is short and boring in a good way, which is what I wanted.
- Fetch. One GraphQL query to GitHub pulls the user, their repos, languages, contribution calendar, pinned items, and contributed-to repos. The lifetime variant fires a few extra queries — more on that below.
- Compute. From that raw data it derives the numbers: total stars and forks, language percentages by bytes of code, current and longest streak from the calendar, and the last ~18 weeks of the contribution grid.
- Render. It builds the SVG as a plain string, section by section, top to bottom. There’s no templating engine and no headless browser — just a small builder that keeps a running vertical cursor and writes one section after another.
A couple of details I’m quietly happy with. The avatar is fetched and base64-embedded directly into the SVG, so the card is fully self-contained — no external image request when someone loads it. And themes are just plain Rust structs; adding a new one is a constant declaration and a single line to register it, no config format to invent.
For the lifetime numbers there’s a wrinkle worth calling out. GitHub’s API only gives you contribution totals for a date range, not a true “since the beginning of time” number. So to get a real lifetime total, statsvg_rs asks for each year from your join date to now — one query per year — and sums them. The per-year requests fan out concurrently so it stays fast even for an account that’s been around a decade.
How I deploy it, and why
Here’s the part I went back and forth on. The project can run as a live HTTP server — there’s an axum server mode and a Dockerfile — but I don’t deploy it that way. I render to static files instead.
A GitHub Action runs on a schedule (every six hours), builds the binary, renders both profile.svg and stats.svg, generates a tiny landing page, and publishes the whole thing to GitHub Pages. The cards you embed are just static files sitting on a CDN.
I picked this for two plain reasons:
- There’s nothing to keep alive. No server to pay for, monitor, or restart at 2am. A scheduled job either runs or it doesn’t, and if it doesn’t, the last good card is still sitting there.
- It’s faster and more reliable for whoever’s looking at it. A static SVG from a CDN always loads instantly. A live server has cold starts, can go down, and gets hit on every single README view — which is exactly how the shared instances of other stats tools end up rate-limited and broken.
Rendering on a schedule means GitHub’s API gets called a handful of times a day on my terms, not once per page view by every visitor. The server mode still earns its keep, though — it’s how I iterate on layout locally. cargo run, hit localhost:3000 with different query params, and watch the card change without waiting on a full render-and-deploy cycle.
How you can use it
If you want your own copy, it’s a fork-and-edit job:
- Fork the repo.
- Open
.github/workflows/render.ymland setSTATSVG_USERto your GitHub login (and a theme/width if you like). - Turn on GitHub Pages with the source set to GitHub Actions.
- Push. Your cards publish to
https://<you>.github.io/<repo>/profile.svgand/stats.svg.
If you want private-repo data counted, generate a classic token with repo scope and add it as a repo secret — otherwise it just uses public data. Then embed whichever card fits where you’re putting it.
That last part is how I run it myself: the stats card lives on my profile README at akshay2211/akshay2211, and the profile card sits in the README of my DrawBox project. Same tool, two genuinely different cards, each tuned for where it’s shown.
What was actually hard
Honestly? Not much fought me. The mechanics — GraphQL, the SVG building, the Actions pipeline — mostly just worked once they were wired up. The real work wasn’t debugging, it was deciding: what belongs on a profile card versus a stats card, what’s noise, what GitHub already shows the viewer so I shouldn’t repeat it. The anti-profile idea came out of that question, not out of any technical struggle.
The few things I had to design around rather than fight were all just realities of the platform. Lifetime totals needing a query per year, as mentioned. GitHub’s image proxy caching embedded SVGs, which is part of why re-rendering every six hours (rather than chasing real-time) is the right cadence — and why there’s a ?v= cache-bust trick in the README for when you want a card refreshed immediately. And the layout being hand-tracked rather than handed to a layout engine, which is more arithmetic but also means there’s no surprise dependency between me and the pixels.
If you’ve got a wall of mismatched cards on your profile and you’ve ever wished one of them did something slightly different, that’s the itch this scratches. The code is on GitHub — fork it, point it at your username, and make it show what you actually want.
Discussion
Leave a comment or react
React with an emoji or start a thread — comments are stored on GitHub Discussions.