Skip to content

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
github.com/ujen5173/-theReadora-
Readora screenshot

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

01

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.

02

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.

03

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.

04

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

01

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.

ts
// 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 } },  }),]);
02

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.

ts
// 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.