Browse Templates/Medium Clone

Medium Clone

Complete database schema for a Medium-like online publishing platform featuring user profiles, publication management with member roles, article publishing with rich metadata, nested response threads, clap reaction system, bookmarks, user and publication follows, reading lists, personalized notifications, and paid subscription management.

PostgreSQL14 tablesWeb App
bloggingpublishingarticlesmedium
Created with ERD Studio

About this schema

An article names an author and, optionally, a publication. Both keys sit on the row, so a piece written for a publication keeps its individual byline — the writer does not disappear into the brand, and pulling an article out of a publication is clearing one column rather than changing who wrote it.

Claps are not likes, and the schema says so with a count column. article_claps holds one row per reader per article with how many times they clapped, which caps a single person's contribution at one row while letting enthusiasm vary. A likes table could not express that without either many rows per reader or losing the degree entirely.

Paid access is decided by two independent facts: articles.is_member_only and the reader's row in subscriptions. Nothing is written per article when someone subscribes, so the gate is a boolean on the content meeting a status and a period end on the reader.

Key relationships

users → articles → publications
Author and publication are separate keys on the article, so a byline survives a piece being added to or removed from a publication.
publications → publication_members
Roles are scoped to the publication, which is how an editor there can be an ordinary reader everywhere else.
articles ↔ tags via article_tags
Many-to-many discovery. Tags are global rows, so a tag page is one join rather than a scan for matching text.
responses → responses (parent_response_id)
Replies nest under a response. Each one carries its own clap_count, so a comment can out-perform the article it sits under.
article_claps / response_claps
Two tables because the targets differ, each storing a per-reader count rather than a single boolean reaction.
bookmarks / reading_list
Saving and organising are separate: a bookmark is the act, a reading_list row puts the same article under a named list.

Design decisions

Claps store a count per reader

A plain like is one row per person and answers how many people. A clap has a degree, and the honest options are a row per clap or a count on one row. The count keeps a reader to a single row no matter how enthusiastic, makes de-duplication a unique constraint, and makes the total a sum of counts — at the price of losing the timing of individual claps, which nothing in the product needs.

publication_id is nullable on articles

Requiring a publication would force every personal post into a one-person publication, duplicating the author as a brand. A nullable key lets the same table serve both a standalone post and an edited piece, and makes moving between them an update. The cost is that every listing query has to decide whether it is showing a person's work or a publication's.

is_member_only is a flag, not a tier

Access here is binary: open to everyone, or open to members. A tier system would allow levels, and this schema deliberately does not — which keeps the paywall check to one boolean against one active subscription. If tiers are later needed, this column becomes a level and the subscription needs the same scale, and both changes have to land together.

read_time_min is stored, not computed

It could be derived from content length on every render, and it is instead written once. Storing it means a listing of fifty articles does not parse fifty bodies, and it allows the estimate to be adjusted for code blocks and images. The trade is that editing an article must recalculate it, or the number quietly becomes wrong.

Tables in this template

usersUsers
10 cols
publicationsPublications
9 cols
publication_membersPublication Members
5 cols
tagsTags
3 cols
articlesArticles
16 cols
article_tagsArticle Tags
4 cols
responsesResponses
8 cols
article_clapsArticle Claps
5 cols
response_clapsResponse Claps
5 cols
bookmarksBookmarks
4 cols
user_followsUser Follows
4 cols
reading_listReading List
5 cols
notificationsNotifications
8 cols
subscriptionsSubscriptions
7 cols

Frequently asked

Why are there two clap tables?
Because the targets are different tables — articles and responses — and a shared table would need either two nullable keys or a polymorphic pair with no foreign keys. Two small tables keep referential integrity on both sides.
How is a publication different from a tag?
A publication is an editorial owner with members and roles; a tag is a label anyone can apply. An article has one publication at most and many tags, which is why one is a key and the other a join table.
What stops someone clapping the same article twice?
A unique constraint on article_id and user_id. The count column is then incremented rather than a new row inserted, which is exactly how a single reader contributes more than one clap without more than one row.
Does reading_list replace bookmarks?
No — bookmarks is the simple saved set, while reading_list adds a list_name so the same article can be filed under a named collection. Keeping both means the default save path stays a two-column insert.
Where is the earnings or payout model?
Not here. subscriptions records what a reader pays; distributing that to writers needs reading time per member and a payout table, which is a separate system from publishing and is deliberately left out.