Notion as a headless CMS: no dependencies, no paid services
Choosing a CMS for a blog is one of the most time-consuming decisions a small site owner makes. Install Strapi — now you're feeding a server. Pick Contentful — a paid tier is waiting. Go with markdown files — every post means a commit + deploy.
I took a third path: Notion itself is the CMS, and the site reads it through the API. madrimov.uz has been running on this setup for a year — zero servers, zero extra costs, and publishing an article comes down to ticking the "Published" checkbox.
Why Notion?
- The writing experience is already there — editor, mobile app, version history.
- Database columns are ideal for metadata: slug, tags, language, date — all properties.
- The API is free and stable; at personal-blog scale you won't hit the limits.
Importantly, this solution needs no npm packages at all. The @notionhq/client SDK is convenient, but plain fetch does the same job.
The core: a 60-line client
Reading a Notion database is a single POST request. Just don't forget pagination:
const NOTION_API = "https://api.notion.com/v1";
async function queryAll(dbId: string) {
const pages = [];
let cursor: string | undefined;
do {
const res = await fetch(`${NOTION_API}/databases/${dbId}/query`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
filter: { property: "Published", checkbox: { equals: true } },
...(cursor ? { start_cursor: cursor } : {}),
}),
next: { revalidate: 300 },
});
if (!res.ok) break;
const data = await res.json();
pages.push(...data.results);
cursor = data.has_more ? data.next_cursor : undefined;
} while (cursor);
return pages;
}Note next: { revalidate: 300 } — that's Next.js ISR. The site talks to Notion once every 5 minutes, not on every request. Even if Notion goes down, the site keeps serving the stale cache.
Rendering blocks
Notion returns page content as a tree of "blocks": paragraph, heading, code, lists. Turning them into React elements is a plain switch:
function renderBlock(block: any) {
switch (block.type) {
case "paragraph":
return <p>{richText(block.paragraph.rich_text)}</p>;
case "heading_2":
return <h2>{richText(block.heading_2.rich_text)}</h2>;
case "code":
return (
<pre>
<code>{plain(block.code.rich_text)}</code>
</pre>
);
default:
return null;
}
}You don't need to support everything — the 8-10 block types you actually use are enough. If the rest return null, the site won't break.
Three rules, one year later
- Keep property names strictly in sync with the code. Rename a column in Notion and the site silently returns nothing. This is the most common "bug".
- Always `try/catch` + empty-array fallback. If the Notion API slows down or returns 500, a blog page serving 200 with an empty list beats a 500 error.
- Mind the signed URL lifetime for images. Notion files come with ~1-hour signed links — if your ISR interval is shorter, the problem solves itself.
Conclusion
Notion + fetch + ISR is a sufficient, no-excess CMS for a personal blog. No server, no subscription, content without deploys. The next step is building automatic distribution (Telegram, LinkedIn) on top of this setup — but that's a topic for another article.