No server. No database. No bill. Just React, Google Apps Script and a GoogleSheet.
by Vishnu
The question that kills side projects
I have started many side projects. Most of them died at the same moment.
Not at a hard feature. Not at a bug. They died when I asked myself one
question:
"Which database should I use?"
That question looks small. It is not. It turns into hosting. Then connection
strings. Then migrations. Then a free plan that goes to sleep every 15
minutes. Then a monthly bill — to store 400 rows that only I will ever read.
So in my last project, I did something different. I did not answer the
question.
My app keeps its data in a Google Sheet. Google Apps Script works as the API.
React is the front end. There is no server for me to run. There is no
database for me to back up. There is no bill.
It works. I spent three weekends learning Docker once, so this is a little
bit annoying.
This article is about the pattern, not my app. At the end I will show you
what I built, and six other things you could build the same way.
What you replace
| You normally need | You use this instead |
|---|---|
| A database server | A Google Sheet |
| Migrations | Adding a column. By clicking. |
| An API server | Apps Script web app (doGet / doPost) |
| An admin panel | ...the sheet |
| Backups | Google Drive history |
| An "export my data" button | It is already a sheet |
Look at the last row. In a normal app, "let the user see their own data" is
work. Someone has to build it.
Here, you get it free. The data was always a sheet. You only built a nicer
window for it.
How it works
Three parts. One of them is a spreadsheet.
┌──────────────────────────────────────────┐
│ BROWSER — React │
│ │
│ Components │
│ │ │
│ Hooks (fetch · cache · state) │
│ │ │
│ api.js │
└──────────────────┬───────────────────────┘
│
│ GET ?action=listTopics
│ POST { action, ...data }
▼
┌──────────────────────────────────────────┐
│ GOOGLE APPS SCRIPT — the "backend" │
│ │
│ doGet / doPost │
│ routes on ?action= │
└──────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ GOOGLE SHEET — the "database" │
│ │
│ Tab: Topics ← the index │
│ id · name · sheetName │
│ │ │
│ ├──→ Tab: Java │
│ └──→ Tab: JavaScript │
└──────────────────────────────────────────┘
How the data sits in the sheet
One tab works as the index. It lists all your groups:
topicId | topicName | sheetName | createdAt | deletedAt
Every group then gets its own tab. One record is one row:
const HEADERS = [
"questionId",
"questionText",
"answerText",
"notes",
"difficulty",
"createdAt",
"updatedAt"
];
Two small choices here save a lot of pain later:
1. Use an id, not a name. topicId is a UUID. Every API call uses the
id. Never the name. So when you rename something, nothing breaks. The name is
just a label. The id is the real thing.
2. Do not really delete. Deleting fills in deletedAt and leaves the row
alone. It is one extra column. It has already saved me once.
Build it in 5 steps
- Make a Google Sheet. This is your database now.
-
Click Extensions → Apps Script. Write
doGetanddoPost. -
Click Deploy → New deployment → Web app. Copy the
/execURL. - Point your React app at that URL.
- There is no step 5. That is the whole point.
Your Apps Script just checks what the app asked for:
switch (action) {
case "listTopics": return listTopics();
case "listQuestions": return listQuestions(params.topic);
case "addQuestion": return addQuestion(body);
case "renameTopic": return renameTopic(body);
// ...
}
I needed ten of these. That was enough for the whole app.
3 problems you will hit
I hit all three. You do not have to.
1. Your POST breaks if you add a header
This one took me a full afternoon.
Add Content-Type: application/json to your POST. This is the normal thing.
Every tutorial does it. And every save will fail.
What makes it worse: your GET calls still work fine. So you look in the wrong
place for hours.
Here is why. That header makes the request "not simple". So the browser first
sends a small check request called OPTIONS. Apps Script cannot answer an
OPTIONS request. It just ignores it.
The fix: send no headers at all. The body goes as plain text, and your
script reads it itself.
It looks wrong. So I left a note in my code, for future me:
/**
* POST deliberately sends no headers.
*
* Adding Content-Type makes this a non-simple CORS request; the browser then
* sends a preflight OPTIONS, which Google Apps Script cannot answer, and every
* write fails. Without headers the body goes as text/plain and the script
* parses it itself (JSON.parse of e.postData.contents). Do not "fix" this.
*/
That note is the most useful thing in my repo.
2. Sheet names ignore CAPITAL letters
I renamed a tab from JS to js.
My app made a second tab called js (2). All my questions looked like
they were gone.
Here is what happened. My code asked "does a tab with this name already
exist?" using getSheetByName(). That function does not care about capital
letters. So JS and js are the same name to it.
The tab compared itself to itself. Then it decided it was a copy.
The fix: compare ids, not names. Ids do not care about capital letters.
3. Your dates are not dates
Write a date into a cell as a string. Sheets saves it as text.
It looks like a date on screen. But it sorts like a word.
The fix: write real Date objects. If you already saved strings, you now
need a script to fix them. So now you do have a migration. Sorry.
It is slow. Build for that.
Every Apps Script call takes 1 to 3 seconds.
That is not broken. But it is slow enough that your app feels tired.
Two things fixed most of it.
Remember what you already loaded
If a user goes back to something they already opened, do not ask again:
const cache = new Map();
// show what we already have, then quietly refresh
Before: 1 to 3 seconds. After: about 1 millisecond.
Do not ask for what you already know
My sidebar shows how many questions each topic has.
My first version loaded every topic's questions just to count them. Even the
topic already open on screen — the one whose questions were already loaded.
With 20 topics, opening the app made 22 requests.
Now the app passes in what it already knows, and only asks about the rest.
Small fix. Very satisfying.
What I built: QMaster
QMaster is where I keep interview questions.
You paste a list of questions. Any format. It reads them, finds duplicates,
and shows you the result. Nothing is saved until you say yes.
The hard part was pasted text. People's lists look like this:
1. What is the JVM and how does it
differ from the JRE?
2. Explain inheritance.
Line 2 belongs to question 1. A computer does not know that.
So I made a simple rule:
- If the text has markers (
1.,-,Q:), then a marker starts a new question, and any line without a marker joins the question above it. - If there are no markers at all, every line is its own question.
It covers both normal cases. And if it gets something wrong, you can fix it
on screen before saving.
the main screen. Show a topic open, questions listed,
sidebar and filter tabs visible.
on a phone. One normal view, one with the menu open.
But QMaster is not the point. The pattern is the point.
6 more apps you could build this way
These are ideas. I have not built them. I have added the sheet columns
for each one, so you can see how little work it really is.
| App | What it does | Your sheet columns |
|---|---|---|
| Task manager | Add tasks, set priority, mark done, filter | id · title · priority · status · dueDate |
| Expense tracker | Add income and costs, see your balance | id · date · amount · category · type · note |
| Portfolio builder | Enter your skills and projects, make a page | id · section · title · description · link |
| Kanban board | To Do, Doing, Done. Drag cards between them | id · title · column · order |
| Quiz app | Questions, options, score at the end | id · question · optionA–D · correct |
| Notes app | Notes with tags and Markdown preview | id · title · body · tags · updatedAt |
Every one uses the same three parts: an index tab, one tab per group, and
doGet / doPost with an action name.
Only the kanban board needs a little thought, because of the order column.
The answer is simple: use a number, and renumber the cards after a drop.
One real advantage over localStorage: your data is not stuck in one
browser on one laptop. It is in Google Drive. It syncs, because Google does
the syncing for you.
Now the honest part
This pattern is easy. But it costs you something. Here it is.
| What you get | What it costs |
|---|---|
| No servers | Every call takes 1 to 3 seconds |
| No migrations | No real search — you filter in the browser |
| Data you can open and read | Not safe for many users at once |
| No bill | No real security |
That last one needs plain words.
An Apps Script web app set to "anyone with the link" has no login and no
password. Anyone who gets that URL can read and change your sheet.
And here is a trap. Putting the URL in a .env file feels like a fix. It is
not. Vite puts VITE_ values inside your JavaScript when it builds. So
the URL is still there, in the code the browser downloads.
It does keep the URL out of GitHub. That matters, because bots scan GitHub
for things like this. But anyone can still find it in DevTools on a live
site.
So: fine for your own personal app. Not fine for real users. If you want
it truly hidden, you need a small serverless function in front of it.
Two more honest things:
- My project has no tests yet. I checked it by hand, with the linter and the browser.
- There is no live demo link. The app points at my own sheet, and I would rather not hand out the key.
Should you do this?
Yes, if:
- It is a personal app
- A few hundred or a few thousand rows
- One person writing the data
- You want to actually finish it
No, if:
- Many people write at the same time
- You need real login
- You need real search
- Speed matters
Here is the thing I really learned.
Infrastructure is a choice. We reach for a database because tutorials
taught us to. But those tutorials solve problems most side projects do not
have.
For 400 rows, a sheet is better. Not because it is easier to code. Because
when something goes wrong, you can open it and look.
Try it on something small. The worst case is you lose a weekend. The best
case is you stop asking the question that keeps killing your projects.
Links
- GitHub: https://github.com/VishnuVenkat07/Q-Master- (it private you want means contact email - vishnuvishnuv08@gmail.com )
- Live demo: not published — see "the honest part"
Written by **Vishnu. If you build something with this, I would like to see
it. Tell me in the comments.


Top comments (0)