DEV Community

Ashar Qaisar
Ashar Qaisar

Posted on

I shipped a real app on a GitHub JSON backend, and it works until it doesn't

I needed a backend for a small discovery feed app. No database was provisionable at the time, and I did not want to pay for one before the app had any users. So I used what I already had: a private GitHub repo, a JSON file in it, and Vercel serverless functions.

This is not a recommendation for every app. It is a record of what worked, what broke, and where it stops working.

The shape

The app is a feed of events, offers, and stores. Viewers are read-only. Only an admin can post, edit, delete, or hide a post. There are no accounts and no payments yet.

The data lives in one file, data/kairo.json, in a private repo. Nothing reads or writes that file from the browser. Every read and write goes through a serverless function, and only the server holds the token that can write to the repo.

The admin page is key-gated. The key is never in the repo and never in client code. The server checks a hash of the key, and without it the admin API returns 401 and no data.

What the server actually does

Votes, saves, and reposts are one per browser, using a device id in localStorage, with server-side dedup. That is gameable by clearing storage, and I say that plainly in the app's own limits. It is a tradeoff, not a feature.

Analytics are computed only from persisted events: per-device voter, saver, and reposter lists, plus a view counter. Seed numbers are ignored in code. When I cut over to real analytics, the old inflated totals were zeroed and the real totals were small. That is the honest number, and it is better than a big fake one.

Admin input is validated on the server: control characters stripped, length caps, country and type checked against an allowlist, and an image URL must be https and return 200 before it is accepted.

Rate limits are persisted in the same data file: votes, saves, and reposts are limited per device and post, views are limited per device and post, with a batch cap. Rapid repeats return 429.

Where it breaks

This works because traffic is low. A JSON file in a repo is not a database. Concurrent writes can conflict, reads are a full file read, and every write is a commit. If traffic grows, the next step is Postgres, not a bigger JSON file.

The data token is currently broad. After launch, it should be replaced with a fine-grained token scoped only to that one repo.

Hosting is on the Hobby plan, which is fine while there is no commercial use. Once paid listings begin, that plan no longer fits, and the cost becomes about $20 a month for Pro.

The lesson

Start with the cheapest backend that is honest about its limits, and write those limits into the code, not just into a README. When the app outgrows it, the migration is a known step, not a rewrite done in panic.


I am Ashar, a full-stack developer and AI integration engineer. More of my work is at https://asharxyz.vercel.app.

Top comments (1)

Collapse
 
alexcodebytes profile image
Oleksandr •

Honestly, using a GitHub repo as a JSON database is the modern-day equivalent of using a Raspberry Pi under your desk as a production server. It works perfectly for a prototype, and the fact that you built in rate limits, server-side validation, and deduplication directly into the serverless layer is solid engineering.

The biggest trap is definitely the concurrent writes issue. If two admins try to post at the exact same millisecond, GitHub's API might accept one and reject the other with a 409 Conflict because the file SHA changed. Did you have to implement a retry loop with a fresh SHA fetch for the admin endpoint, or do you just ask the admin to hit submit again?

Also, massive respect for zeroing out the fake seed numbers when cutting over to real analytics. That takes a lot of integrity!