</>
  • Home
  • About
  • Skills
  • Experience
  • Case Studies
  • Blog
  • Projects
madrimov.uz

Keling, jiddiy
tizim quramiz

madrimov5014@gmail.com→
</>© 2026 Madrimov Xudoshukur
GitHubTelegramKanalEmail
← Blog
July 17, 2026

Notion as a headless CMS: no dependencies, no paid services

BackendArxitekturaNext.js

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

  1. 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".
  2. 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.
  3. 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.

Articles like this, every week

New articles, hands-on engineering experience and projects — on the Telegram channel.

Join the channel →

Related articles

  • Why in-process events and cron break in multi-core Node.js — and how to fix it properlyJune 18, 2026
  • From NestJS + Prisma to Bun + Hono + Drizzle: why I switchedJune 16, 2026
  • Bun + Hono: a year in productionJune 14, 2026