Why I built my own blog instead of writing on Medium

5 min read

This is the first post in a short series about the blog you are reading. I built it myself, from the public pages down to the editor I am typing this in, and I want to write down how it works while the decisions are still fresh.

This part is the overview: why I built it, what it is made of, and how the code is organised. The later parts go deep on one area each.

Why not just use Medium

I like writing on Medium. The editor is the best thing about it. You open a page, there is a title, a blinking cursor, and nothing else. A small "+" appears on an empty line when you want an image. A toolbar appears when you select text. Everything else stays out of the way.

What I did not like is everything around the editor. The posts live on someone else's domain, behind someone else's design, with someone else's rules about who can read them. I wanted my writing on my own site, next to my portfolio, with pages I control completely.

So the goal was simple to state: keep the writing experience I liked, and own the rest.

The editor in this project is inspired by Medium's. The interactions are modelled on it; the code is my own, built on the open-source Tiptap editor.

What the project is

It is a single-author blog with two halves:

  • The public site: a welcome page, a searchable list of posts, the post pages, topic pages, an About page, an RSS feed and a sitemap.

  • The admin: a private area where I write, upload images, schedule posts and see who tried to sign in.

There is exactly one user, me. There are no comments, no accounts for readers and no newsletter. Leaving those out is what kept the project small enough to finish.

[Screenshot to add: The home page, with the welcome heading and the three latest posts]

[Screenshot to add: The admin dashboard after signing in]

The stack

Every piece was chosen to run on a free tier without feeling like a toy.

How the code is laid out

The folder structure mirrors the two halves of the product.

app/
  (site)/        public pages: home, blog, blog/[slug], tags/[tag], about
  admin/
    (panel)/     dashboard, posts, media, sign-in attempts, settings
    (editor)/    the post editor, full width, no sidebar
  api/           auth, upload, revalidate, health
  login/         Google sign-in
components/      ui/, site/, admin/, editor/
db/              schema, client, migrations, seed
lib/             auth, posts queries, content rendering, R2, revalidation
proxy.ts         the gate in front of /admin

The folders in brackets are route groups. They do not appear in the URL; they only let different parts of the app have different layouts. That is how the admin has a sidebar on the dashboard but a clean, full-width page in the editor: (panel) and (editor) each have their own layout.tsx.

The same trick separates the public site from everything else. Pages inside (site) share the public header and footer; the admin and the login page do not.

One document, two renderers

The decision that shaped the most code is how a post is stored. The editor does not save HTML. It saves the document as JSON:

{
  "type": "doc",
  "content": [
    { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "How it works" }] },
    { "type": "paragraph", "content": [{ "type": "text", "text": "Posts are stored as JSON." }] }
  ]
}

That JSON goes into a single jsonb column. When someone opens a post, the server turns it into HTML using the same list of extensions the editor uses. One list, shared by both sides, is what keeps the editor and the published page from drifting apart. Part 3 covers this in detail.

Things that surprised me

Next.js 16 is not the Next.js I knew. middleware.ts is now proxy.ts. Route params are a promise you have to await. Page props have generated types. None of this is hard, but a lot of what I remembered was wrong, and I had to read the current docs rather than trust my memory.

export default async function PostPage(props: PageProps<"/blog/[slug]">) {
  const { slug } = await props.params;
  const post = await buildSafe(() => getLivePostBySlug(slug), null);
  if (!post) notFound();
  // ...
}

Free tiers have sharp edges. Vercel's free plan includes a limited number of image optimisations each month, and after that new ones fail. Neon puts the database to sleep when nobody is using it. Each of these changed a line of configuration somewhere, and the last part of this series collects them.

The editor is where the detail lives. The public site is mostly reading from a database and rendering. The editor is the part where every small behaviour is visible, and it is the largest single piece of code in the project.

The whole series

  1. Why I built my own blog instead of writing on Medium (this post)

  2. Building a Medium-style editor with Tiptap

  3. From editor JSON to fast, crawlable pages

  4. Publishing without a redeploy: drafts, scheduling and ISR

  5. A one-person admin: Google sign-in, a sign-in log and direct uploads

  6. Design details and shipping on free tiers