Notion-Powered Blog

Headless CMS Blog with Notion as Backend

Write in Notion, publish to a fast Next.js site automatically — no CMS dashboard, no content migration, just your existing notes workflow.

Notion-Powered Blog
0.9sLoad Time
99.9%Uptime
92/100PageSpeed

Problem

I wanted a place to write — but every CMS I looked at added friction: learn a new editor, migrate content, maintain a database, or pay for a hosted solution. I already write in Notion. My notes, drafts, and thinking are all there. The problem was getting that content onto a fast, SEO-friendly website without changing my writing workflow at all.

Solution

I built a Next.js frontend that reads directly from a Notion database via the official API. Each Notion page becomes a blog post — published date, category, and cover image pulled from Notion properties. The frontend renders Notion blocks as semantic HTML with Tailwind styling. ISR means the site is fast for readers and auto-updates when I publish in Notion.

How I Thought Through It

The first question was whether to use a library like `notion-to-md` or build my own block renderer. Libraries abstract the rendering but hide how Notion blocks actually work. I decided to write the renderer myself — it's not that many block types, and owning it means I can style each block exactly how I want without fighting a library's opinions.

The second question was caching strategy. A blog post doesn't change often, but when I publish something new I want it live quickly. ISR with a 60-second revalidation window is the right compromise: pages are static and fast for readers, but the site picks up new posts within a minute of publishing.

Key Decisions & Why

Decision 1 of 5

Custom Notion block renderer instead of a third-party library

I handle 13+ block types (paragraphs, headings, code, quotes, images, callouts, toggles, tables, columns, dividers) and render each with inline Tailwind classes. Full control over styling with zero library abstraction overhead.

Trade-off

More code to maintain, but the result is exactly what I want visually and I understand every line of it.

lib/notion.ts
1switch (block.type) {
2 case "paragraph":
3 notionBlock.richText = block.paragraph?.rich_text || []
4 break
5 case "heading_2":
6 notionBlock.richText = block.heading_2?.rich_text || []
7 break
8 case "code":
9 notionBlock.content = block.code?.rich_text
10 ?.map((t) => t.plain_text).join("") || ""
11 notionBlock.language = block.code?.language || "text"
12 break
13 case "callout":
14 notionBlock.icon = block.callout?.icon?.emoji || ""
15 notionBlock.richText = block.callout?.rich_text || []
16 break
17 // 13+ block types handled...
18}
1 / 5

Tech Stack

Next.js 14React 18TypeScriptNotion APITailwind CSSVercelISR

Architecture Overview

The architecture separates into two layers. The data layer lives entirely in lib/notion.ts, exporting two async functions: getBlogPosts() queries the Notion database and returns typed BlogPost objects (title, slug, date, category, excerpt, cover, tags), and getNotionBlocks() fetches and recursively parses a page's full block tree into a typed NotionBlock hierarchy. Both are called exclusively from Server Components — no client-side Notion API access, no API proxy, no hydration cost for post content.

The presentation layer uses three caching strategies depending on page type. The homepage and category listings use ISR with revalidate = 60 — pre-rendered, served from CDN, updating automatically within a minute of a new post being published. Individual post pages use force-dynamic — always fresh from Notion so edits appear immediately without a redeploy. Category listing pages use generateStaticParams() with the four fixed categories — built at deploy time, served from CDN edge with zero per-request compute.

The block renderer is a recursive TypeScript function: it takes a NotionBlock and returns JSX. Each block type maps to a styled HTML element with Tailwind classes applied directly — no extra CSS files, no className prop drilling. Rich text annotations (bold, italic, inline code, underline, strikethrough, links) are applied by iterating the richText array on each block's segments. The renderer handles 13+ block types: paragraph, heading_1/2/3, bulleted/numbered list items, to_do, code, quote, callout, image, video, divider, toggle, column_list, and column.

Images from Notion are passed through Next.js Image with configured remotePatterns for Notion's CDN. This converts them to WebP, lazy-loads them, and serves them from Vercel's CDN — significantly faster than loading directly from Notion's servers.

Content Pipeline

The pipeline has zero friction by design: write in Notion, check a checkbox, wait a minute, post is live.

When I write a post, it stays as a draft — the Published database property is unchecked. getBlogPosts() filters on this property, so draft pages are never returned regardless of the ISR cache state. When I'm ready to publish, I check the Published toggle and optionally update the Date property. The listing page's 60-second ISR window means the post appears on the homepage and in the relevant category listing within about a minute — no deploy, no CMS login, no content migration.

When a visitor loads the post page, getNotionBlocks() fetches the page's top-level block children in a single API call (page_size: 100). For each block where block.has_children === true, the function recurses using Promise.all — all child fetches at a given depth run in parallel, not sequentially. Total fetch time is proportional to nesting depth, not block count.

Rich text formatting is applied at the leaf level. Each block's richText array contains text segments annotated with bold, italic, code, underline, strikethrough, and link metadata. The renderer maps these to strong, em, code, u, del, and a elements with Tailwind classes applied inline.

Cover images come from the Notion page's cover property — either a Notion-hosted file URL or an external URL. Both are passed through Next.js Image. A cover image that Notion would serve as a 400KB+ JPEG renders as roughly 60KB WebP from Vercel's CDN.

Why This Stack

Notion as the CMS: this was as much a quality-of-life decision as a technical one. I already have years of content in Notion — notes, drafts, references. Using it as the backend meant no content migration, no new editor to learn, no separate writing workflow. The Notion API is stable and well-documented, and the official @notionhq/client SDK handles authentication and retries. The main limitation is no webhook support for page changes — hence the polling-based ISR approach.

Next.js App Router: Server Components were the critical enabler. The Notion API client runs entirely on the server — it never ships to the browser, the API key never leaks, and there's no useEffect fetch waterfall on the client. Route-level caching (revalidate and force-dynamic) gives precise freshness control per page type without any custom middleware.

ISR over SSG: with SSG, publishing a new post requires a full redeploy — every page is built at deploy time and frozen. ISR revalidates the listing page automatically every 60 seconds, so new posts appear within a minute of publishing with no CI/CD involvement.

Custom block renderer over notion-to-md + rehype: every existing Notion-to-HTML library either produces Markdown (requiring a second parse step) or outputs opinionated HTML I'd have to fight with CSS overrides. Writing the renderer myself took one afternoon but the result is fully mine — every block type looks exactly how I want it.

Vercel: zero config for ISR, automatic branch previews, global CDN, native Next.js support. For a personal project, no server management or SSL configuration needed — just git push.

What I'd Do Differently

Post pages use force-dynamic, meaning every visitor request hits the Notion API. For a post with concurrent readers, that's redundant — the content hasn't changed. The right fix: use ISR on post pages too (revalidate = 300) and add on-demand revalidation via a webhook. When Notion supports webhooks natively (or via a Notion integration + Zapier trigger), fire a POST to /api/revalidate which calls revalidatePath() for the updated post. Both caching and freshness, without hitting Notion for every visitor.

The URL structure uses the Notion page ID (/blog/[pageId]) — stable, but not human-readable. A slug-based URL (/blog/why-i-chose-notion-as-my-cms) would be better for SEO and shareability. The fix: store a Slug property in the Notion database and use generateStaticParams() to pre-build every post at deploy time.

The block renderer passes Notion's CDN file URLs directly to Next.js Image. Notion file URLs expire after a set period, which means embedded images in cached posts can eventually 404. The robust fix is to proxy images through a serverless function that re-fetches from the Notion API on each request, or copy images to an owned CDN bucket at publish time.

Want to discuss this project?

I'm happy to walk through the decisions in more depth.