Building · Notion · Cloudflare · Automation · Web Dev
I Built a Blog That Publishes From Notion
August 14, 2026 · 8 min read
My blog has no editor.
No admin panel. Nothing that looks like a place to write. I don’t want to deal with that.
I mean, I write in Notion, always been that way. Huh, it occurred to me, do I really need to reinvent the wheel?
What if, I flip a dropdown from Draft to Published.
Then some time later, usually within the hour and sometimes within a minute, the post shows up on my actual website.
That would feel magical. But maybe it’s difficult?
It isn't.
I’ll go over the parts, piece by piece.
Where the Writing Actually Happens
The source of everything is a Notion database, which is really just a (database) table.
Each row is a post.
Alongside the actual writing, a handful of columns hold the metadata a website needs:
| Field | Purpose |
|---|---|
| Title | The post title |
| Description | Short summary |
| Category | Where the post belongs |
| Tags | Topics associated with the post |
| Publish Date | When it was published |
| Status | Draft or Published |
And one column matters more than the rest:
Status
It has exactly two options:
Draft
Published
That single field is the whole publishing mechanism.
Nothing else in this system asks a more complicated question than:
Is this row's Status set to Published?
Whatever you build something like this on top of, whether it's a spreadsheet, a headless CMS, or a plain folder of files, you want exactly one field like this.
Unambiguous. Machine-readable. Impossible to misinterpret.
Not a naming convention you're trusting yourself to remember.
The Site Itself, and Why It Needs Help
The site is built with Next.js running on Cloudflare Static Pages , the site being exported as a fully static set of files.
That distinction matters more than it sounds.
A static site has no process running in the background.
Nothing can wake up, check Notion, and notice that something changed.
Every page that exists, exists because it was generated ahead of time.
So there needs to be something between:
"I published something in Notion."
and
"The website shows it."
Something outside the website has to do the work, either on a schedule or in response to an event, and then tell the site to rebuild itself from scratch.
The Script That Actually Reads Notion
That work is handled by a small script that runs right before the site builds.
It:
- Authenticates to Notion using an API key scoped to this integration.
- Finds rows where Status = Published.
- Reads the actual Notion page content.
- Converts the Notion blocks into the format the site expects.
- Writes each post out as its own file.
- Removes files for posts that are no longer published.
The important part is that it isn't just reading metadata.
It reads the actual Notion blocks:
- Paragraphs
- Headings
- Lists
- Code blocks
- Links
- Other supported content
Then it turns those blocks into something the site's existing templates already know how to render.
The part that's easy to get wrong
What happens when a post is removed?
Or when I change it from Published back to Draft?
The first version of my script only ever added new files.
Nothing ever told an old one to leave.
So I changed the sync logic to compare:
What Notion says should exist
against
What currently exists locally
Anything left over from a previous run that isn't in the current set gets deleted.
So now:
Publish in Notion
→ Post appears on the site.
Un-publish in Notion
→ Post disappears from the site.
That's the difference between an importer and a real mirror.
If you're syncing data between two systems, don't just ask how new things get added. Ask what happens when something disappears.
How Notion Becomes an Actual Blog Post
So how does a Notion page actually become the page you're reading right now?
Well there are two separate pipelines happening.
First Pipeline: Notion → Markdown
First, the build script talks to Notion and pulls the actual page content as Notion blocks.
A few packages handle that part:
| Package | What it does |
|---|---|
| @notionhq/client | Talks to Notion's API and fetches the database and page content |
| notion-to-md | Converts Notion blocks into Markdown |
| gray-matter | Reads the metadata stored in Markdown frontmatter |
So the flow looks roughly like this:
Notion page
→ Notion API
→ Notion blocks
→ notion-to-md
→ Markdown file
At this point, the Notion page has become a normal Markdown file sitting in the repository.
Second Pipeline: Markdown → The Page You Actually See
The second pipeline happens when the site builds.
That Markdown file gets passed through a few more tools that turn it into the actual HTML and styling used by the blog:
| Package | What it does |
|---|---|
| react-markdown | Turns Markdown into React components |
| remark-gfm | Adds GitHub-flavored Markdown features like tables and strikethrough |
| rehype-slug | Gives headings stable IDs for anchor links |
| unist-util-visit | Powers the small custom plugin that turns callout syntax into styled callout boxes |
| shiki | Syntax-highlights code blocks at build time |
The important part about Shiki is that the highlighting happens during the build.
The visitor doesn't need to download a syntax-highlighting library just to see a colored code block.
The HTML is already prepared when the page is generated.
So the complete content pipeline is basically:
And that's actually one of the things I like most about this setup.
I'm not rendering Notion directly on every page request.
I'm not shipping a Notion SDK to the browser.
I'm not making visitors wait for an API call to Notion.
Notion is simply the writing interface and source of truth.
By the time someone visits the blog, all of that work has already happened.
They just get a static page. Since again we’re also on Cloudflare Static Pages.
Two Different Things Can Trigger a Rebuild
Once the files exist, something still has to actually rebuild and redeploy the site.
I use two separate triggers for that.
The Fast Path
A small always-on listener, built as a Cloudflare Worker, waits for Notion to send a signed notification whenever something in the database changes.
That's it.
It's fast because it's event-driven.
Nothing happens until something actually changes.
The Slow Path
A scheduled job runs once an hour, regardless of whether anything changed, and pings the exact same rebuild trigger.
Below is my .github/workflow on my repo.
It needs that CLOUDFLARE_DEPLOY_HOOK_URL so Cloudflare Pages will rebuild and redeploy the site now. (I mean, when invoked)
Fast path: live within about a minute.
Slow path: live within an hour, even if the fast path fails.
I think that’s good enough, no?
The Whole Shebang
In one sentence
I write in Notion → Notion tells the system something changed → the site rebuilds → the sync script pulls Published posts → the updated site goes live.
What's Actually Doing the Work?
Yeah it’s everything below that’s working hand-in-hand.
| Piece | What it does |
|---|---|
| Next.js | Builds the static site |
| Notion API | Provides the source content |
| TypeScript | Runs the sync logic |
| Notion official client | Talks to the Notion API |
| Notion block converter | Turns Notion content into site-ready content |
| Syntax highlighter | Highlights code during the build |
| Cloudflare Worker | Handles the fast rebuild trigger |
| GitHub Action | Provides the hourly backup trigger |
| Cloudflare Pages | Builds and serves the final site |
The Engineering Was in the Boring Parts
No fancy tools right.
It was in getting the cogs correct:
One source of truth
Notion decides whether a post exists on the site.
One unambiguous switch
Status = Published means publish.
Addition and removal
The sync doesn't just create files. It removes things that are no longer supposed to exist.
A fast path with a safety net
The webhook gets posts live quickly.
The hourly trigger makes sure a silent failure doesn't stay silent forever.
Static sites need an external trigger
Because there's no server sitting around watching for changes, something else has to tell the build system when to work.
And That's the Whole Trick
I don’t want to deal with CMS.
I don’t want to deal with an Admin Panel.
I don’t want to put a custom editor on my Blog.
I don’t want to go to a terminal instance just to publish a post.
There's just a Notion page, a status dropdown, and a handful of small pieces doing exactly what they're supposed to do.
I write.
I publish.
The website updates itself.
Notion is where I write.
blog.neilmarc.com is where it ends up.
Easy, peasy, lemon, squeezy ryt?