
Why I Reach for Notion as a Blog CMS in My Next.js Projects
Headless options like Strapi and Contentful exist, so why Notion? My practical experience and a comparison.
The problem
Most Next.js blogs run on MDX or Markdown files. That is fine for a simple project, but the trouble starts once you are writing for several projects, or you want someone non-technical to add content too:
- Every post needs a git commit and a deploy
- Adding images is tedious
- Writing from a phone is close to impossible
- There is no draft/publish flow
So why not Strapi or Contentful?
I asked myself that a lot. I tried both, and both have real strengths. But for my use case Notion won.
Strapi
Strapi is an open-source, self-hosted headless CMS. At first glance it looks great:
- It runs on your own server, so the data stays with you
- You can create custom content types
- REST and GraphQL APIs are built in
In practice, though, I ran into these:
- Hosting cost. Strapi is a Node.js application and it needs a server. For a small blog that is $5-15 a month. With Notion it is zero.
- Maintenance. Strapi has to be kept current, and major upgrades (v4 to v5, say) are full of breaking changes with a tedious migration.
- Editing experience. The admin panel is functional, but it does not compare to Notion's editor. Writing in Notion is genuinely pleasant; in Strapi it feels like filling in a form.
- Setup time. Installing Strapi, defining content types, setting API permissions, deploying: two to three hours minimum. Creating a Notion database and getting an API key takes five minutes.
Contentful
Contentful is managed SaaS. No hosting to worry about, but other worries instead:
- The free plan is limited. 25K API calls a month and 5 users. Once traffic grows you are on a paid plan quickly.
- Aggressive pricing. The Team plan starts at $300 a month. For a blog that is far too much.
- Content modelling is involved. Even a simple blog post needs a content type, field definitions and validation rules.
- Vendor lock-in. When you want your content out, the export formats are limited.
Sanity, Ghost, the others?
- Sanity is good, but you have to learn its own GROQ query language. Overkill for a small project.
- Ghost is blog-specific and nice, but the self-hosted version needs a server again, and the Pro plan starts at $9 a month.
Why Notion wins
I was already using Notion for daily notes, project management and documentation. Managing blog posts from the same place is a real convenience. But the actual reasons:
- Zero cost. Notion's free plan is enough for personal use, and API access is free too.
- Zero maintenance. No hosting, no upgrades, no database administration. Notion handles it.
- A great editor. Code blocks, tables, toggles, callouts and embeds are native blocks. Far more flexible than Markdown and far more pleasant than Strapi's form-based editor.
- Mobile access. I can write and publish a post from my phone without touching git.
- Five-minute setup. Create the database, get an API key, connect it to Next.js. That is it.
- I already use it. No extra tool to learn; Notion is already part of my daily workflow.
How it works
The basic flow:
- Create a Notion database (with Title, Slug, Date, Tags, Status and Cover properties)
- Pull that database into Next.js through the Notion API
- Turn Notion blocks into React components with a block renderer
- Let ISR refresh the page every 60 seconds
lib/notion.ts
import { Client } from '@notionhq/client'
const notion = new Client({ auth: process.env.NOTION_TOKEN })
export async function getPosts() {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
filter: {
property: 'Status',
select: { equals: 'Published' }
},
sorts: [{ property: 'Date', direction: 'descending' }]
})
return response.results
}
Rendering Notion blocks
The Notion API returns every paragraph, heading and code sample as a separate "block". I map those onto React components:
components/NotionBlock.tsx
function renderBlock(block) {
switch (block.type) {
case 'paragraph':
return <p>{renderRichText(block.paragraph.rich_text)}</p>
case 'heading_2':
return <h2>{renderRichText(block.heading_2.rich_text)}</h2>
case 'code':
return <CodeBlock language={block.code.language}>
{block.code.rich_text[0].plain_text}
</CodeBlock>
case 'image':
return <img src={block.image.file?.url || block.image.external?.url} />
case 'callout':
return <Callout emoji={block.callout.icon?.emoji}>
{renderRichText(block.callout.rich_text)}
</Callout>
default:
return null
}
}
Side by side
| Notion | Strapi | Contentful | |
|---|---|---|---|
| Cost | Free | $5-15/mo server | $0-300/mo |
| Setup time | 5 min | 2-3 hours | 30 min |
| Hosting | None (SaaS) | Self-hosted | None (SaaS) |
| Editor quality | Excellent | Average | Good |
| Writing on mobile | Yes | Limited | Yes |
| API | REST | REST + GraphQL | REST + GraphQL |
| Maintenance | None | High | Low |
| Image CDN | Built in (expiring) | Your own server | Built in |
Things to watch for
- Notion image URLs expire. The URL of a
fileimage is valid for one hour, so you need to save those into your own storage at build time.externalimages do not have this problem. - Use ISR with on-demand revalidation. When you update a post in Notion, a webhook can tell Next.js to rebuild that page.
- Rate limits. The Notion API allows three requests per second. Once you have a lot of pages you need a caching strategy.
- Customisation is limited. You cannot create custom field types; Strapi is far more flexible there. For a blog, the properties Notion offers are more than enough.
Conclusion
If you are looking for a CMS for a simple-to-moderate blog and you already use Notion, you do not need anything else. Rather than standing up and maintaining Strapi or Contentful, spend that time writing.
If you need custom content types, complex relations and multi-tenant structures, Strapi makes sense. But for a blog plus a few pages, Notion is more than enough.
In upcoming posts I will share a step-by-step Notion API integration tutorial and how to set up on-demand revalidation with a webhook.