Consumer platform
Readora
A place to write and read serialised stories, like Wattpad
Writers publish stories a chapter at a time, schedule what comes next, and charge for chapters if they want to. Readers keep a library, follow writers and buy coins once instead of paying per chapter. The interesting part was storage: the chapter text and everything around it want completely different databases.
- Role
- Solo — full stack, data modelling, payments
- Timeline
- 2025
- Status
- Live
- Stack
- Next.js, TypeScript, Prisma, PostgreSQL, MongoDB, tRPC, Stripe

01 — Context
The problem
A story site looks like a blog until you look at the numbers. One serialised story can run to hundreds of chapters and millions of characters, and readers open them one at a time, out of order, usually on a phone with a slow connection.
Everything around the text is the opposite: writers, stories, chapter records, release dates, purchases, follows. Lots of small records that want joins and rules holding them together.
Keeping both in one database means either a relational one stuffed with enormous text columns, or a document one where I write the payment rules by hand. Neither is a trade I wanted to make.
02 — Decisions
How I approached it
Two databases, split by what they hold
Postgres holds everything small and connected: users, stories, chapter records, coin balances, purchases. MongoDB holds the chapter text itself, filed under the Postgres chapter ID. Each one does the job it is good at.
Chapters stored in pieces
A long chapter is saved as several ordered pieces rather than one huge document. The reader pulls only the pieces it is about to show, so opening chapter 1 of a 400-chapter story does not drag the rest along with it.
Coins instead of a card charge per chapter
Readers buy coins once and spend them chapter by chapter. One card payment instead of thirty: cheaper in fees, less to click through, and far easier to check the books afterwards.
Drafts and scheduled chapters
A chapter is a draft, scheduled or published depending on a status and a release time on its own record. The same query serves all three by changing one condition, so there is no second code path to keep in step.
03 — Structure
How it fits together
- Interface
Next.js App Router · Reader view · Writer dashboard
The reader only fetches the part of the chapter on screen.
- API
tRPC · Stripe webhooks · Idempotency keys
Anything involving money can be repeated without doing damage.
- Relational
PostgreSQL · Prisma · Wallets & purchases
Decides what exists, what is visible and who paid for it.
- Document
MongoDB · Chapter text in pieces
Long text, fetched a slice at a time.
04 — Problem solving
What was actually hard
Problem
Two databases, one truth. Publishing a chapter writes the record to Postgres and the text to MongoDB. If the second write fails, the site shows a chapter with nothing in it, which is the worst thing a reading app can do.
Solution
Postgres decides what is visible, so the text is written first and the record is only flipped to published once that has worked. Text with no record is invisible and can be cleaned up later. A record with no text — the failure a reader would actually see — cannot happen.
// Text first, visibility second.// A crash in between leaves stray text, never an empty chapter.await mongo.chapterChunks.insertMany(chunksFor(chapterId, body));await prisma.$transaction([ prisma.chapter.update({ where: { id: chapterId }, data: { status: "PUBLISHED", releasedAt: new Date() }, }), prisma.series.update({ where: { id: seriesId }, data: { chapterCount: { increment: 1 } }, }),]);Problem
Spending the same coins twice. Two quick taps on the same paid chapter could both read a balance of 10, both take 10 away, and both succeed. The reader pays once and unlocks twice, or the balance goes below zero.
Solution
Spending happens in one statement that only takes the coins if the balance is still high enough, and the purchase row has a unique constraint on the reader and chapter together. The second request either finds too few coins or hits the constraint and is treated as 'already bought'. No locks, no queue in front of it.
// The WHERE clause does the locking: no balance, no deduction.const spent = await prisma.wallet.updateMany({ where: { userId, balance: { gte: price } }, data: { balance: { decrement: price } },});if (spent.count === 0) throw new InsufficientCoins();// Unique (userId, chapterId), so a repeated request changes nothing.await prisma.purchase.create({ data: { userId, chapterId, price } });05 — Result
Where it landed
- Storage
- Postgres + MongoDB
- Chapters
- Saved in pieces
- Publishing
- Drafts + scheduling
- Traffic
- 1k+ visits a month
What I'd do differently
Splitting the databases was right for reading speed, but it makes every write that touches both harder than it needs to be. With a smaller library I would stay on Postgres alone and split only once the text columns actually started to hurt. I would also write the job that checks the coin ledger before building the payment flow rather than after — knowing the numbers add up is worth more than shipping a week earlier.