How to Write in Notion and Auto-Sync to a Static Blog
If you already like writing in Notion but your site is built with Astro or another file-first static stack, there is a small workflow gap that gets annoying faster than it should.
Drafting in Notion feels smooth. Moving sections around is easy, images are easy, the page stays readable while you are still shaping the piece. Then publishing starts, and suddenly you are back in the repo copying content over, fixing frontmatter, generating an English version, running a build, pushing changes, and waiting for deployment. The first half feels like writing. The second half feels like maintenance work.
That is the exact gap I wanted to remove. I still wanted Notion as the drafting surface, but I did not want to turn the Astro blog back into a backend-heavy content system. So I ended up with a narrow pipeline: write the article in Notion, let GitHub Actions pull the page, turn it into Markdown files in the repo, generate the English version and slug, run build validation, and then continue into the existing production deploy flow.
If you have not yet moved your blog into a file-first setup, Astro vs WordPress: Why I Prefer Markdown and GitHub Actions for a Developer Blog is the better starting point. If you already publish directly from Markdown in the repo, Write Markdown Locally, Deploy on git push: My Bilingual Blog Publishing Workflow covers that side. This post sits in between those two. It is about keeping the writing comfort of Notion without giving up the structure and release discipline of an Astro content repo.
My first instinct was the same one most people have: make it event-driven right away. Publish in Notion, fire a webhook, trigger a Worker, write files, deploy production. On paper that sounds cleaner. But the more I mapped actual failure paths, the more I pulled back. For a personal blog, the most valuable thing in version one is not speed. It is debuggability. So I kept the first version intentionally plain. The article body lives in the Notion page body, properties only hold metadata like title, summary, category, tags, slug, and status, each run processes one page at a time, the default queue rule is publish = true plus status = Ready, and updating an existing post requires reusing the original slug and explicitly allowing updates.
None of that looks impressive, but it makes the pipeline easy to reason about. If a page never entered the queue, the metadata is probably wrong. If English generation failed, that is one layer. If the build failed, go back to the content schema. If deployment never ran, look at the next workflow. I would rather have a slower first version that is obvious to debug than a “real-time” setup that becomes annoying the first time it breaks.
That tradeoff makes even more sense for a solo blog. A team may care about instant publishing, multi-editor review states, and richer workflow events. What I care about is simpler: if I finish a post at night, can I get it onto the site the next morning without risking accidental overwrites? Polling is not glamorous, but for this job it is very hard to hate.
Once you strip the tooling names away, the workflow is straightforward:
Notion page body
-> mark page as Ready + publish
-> GitHub Actions pulls markdown
-> generate English title, description, body, and slug
-> write files into src/content/posts/zh-cn and en-us
-> run build validation
-> push to main
-> trigger the production deploy workflow
-> write status, links, and commit SHA back to Notion
The two things I cared about most were these. First, Notion is only the writing surface, not the final published artifact. The live site still comes from Markdown files in the repository. Second, automation should not come at the cost of losing overwrite protection. That is why the repository still ends up with a very plain content shape:
src/content/posts/
├── zh-cn/
│ └── notion-to-astro-auto-publish-workflow.md
└── en-us/
└── notion-to-astro-auto-publish-workflow.md
Both language versions share the same path, which keeps Astro routing and aggregation clean. On the Notion side, the required inputs stay limited too: title, summary, category, tags, slug, publish state, queue state, auto-translate, and the explicit update flag. The body itself stays in the page content, not in a property. That sounds obvious, but it matters. The script pulls exported markdown from the page. It does not rebuild the article by manually walking blocks and guessing how to serialize them. As long as the page can be exported cleanly by Notion, it can be turned into a normal content file without much extra ceremony.
If the more practical question is “how should I actually write inside Notion so the article syncs cleanly to a static blog,” my answer is simple now: treat Notion like a comfortable Markdown editor, not like a visual layout playground. That means normal heading levels, normal paragraphs, fenced code blocks for commands and config, images placed near the paragraph they belong to, and standalone lines for links or videos you may want to upgrade later. The more you depend on Notion-only presentation tricks like heavy visual bookmarks, multi-column layouts, or overly decorative blocks, the more cleanup work you are likely to do after export.
What works best for me is a very plain shape. Open with a problem-led section. Carry the main explanation in a few long paragraphs. Drop in code blocks when commands or config actually matter. Keep images close to the relevant paragraph instead of treating them like a gallery. And if a reference link or video matters, give it its own line. At that point the Notion page is already behaving a lot like a clean Markdown source file, which is exactly why the sync step becomes reliable.
The most conservative pattern looks roughly like this:
# Title
Open with two or three paragraphs that explain the problem and the decision.
## One key section
Keep writing.
```bash
pnpm build
node scripts/notion-auto-publish.mjs process
```
Then add pitfalls, limits, or recovery notes.
https://example.com/some-reference
Once you write this way, the sync step stops feeling like “convert a fancy Notion page into a blog” and starts feeling like “take a clean source draft from Notion and drop it into the repo.” That mindset change matters a lot.
How the script is split: three commands, three jobs
The script entrypoints stay narrow as well:
node scripts/notion-auto-publish.mjs process
node scripts/notion-auto-publish.mjs finalize-success
node scripts/notion-auto-publish.mjs finalize-failure
process does the real work. It selects one eligible page, retrieves the body, generates the English side, writes both Markdown files, and then lets the build and release chain continue. The success and failure callbacks only handle the Notion write-back. I prefer that split because it keeps each stage honest. It does not turn into one oversized script that tries to query Notion, translate content, validate build output, push Git, and patch statuses all in the same breath.
The more I used this setup, the less I cared about the translation step itself and the more I cared about slug protection. Because once publishing becomes automated, the biggest risk is usually not “I clicked one extra button.” It is “I thought I was publishing a new post, but the system thought I meant to replace an old one.” That risk gets very real when titles are similar and slugs are generated automatically.
So I made the rule intentionally strict. New posts should ideally get a unique slug. If the repo already contains that slug, fail by default. Only allow replacement when update permission is explicitly enabled. That makes the pipeline conservative on purpose. It would rather stop and complain than silently guess that I meant to overwrite something. For a blog, I think that is the right bias. Recovering from a missed publish is easy. Recovering from an accidental replacement is usually more annoying.
Another thing that shaped this setup was the behavior of OpenAI-compatible gateways. I have already run into awkward cases: a 200 OK response with no usable body, a non-streaming response that came back empty while streaming worked, or a schema that looked accepted but still returned the wrong fields. That is why the translation layer now has multiple fallbacks. Not because I wanted a clever script, but because once English generation and slug generation sit inside a publishing path, that layer cannot be fragile. I do not treat the model as a magical publisher. I treat it as one replaceable transformation step inside a larger system. It can help produce the English draft, the title, and the description, but the hard boundaries still belong to the repo: content schema, build validation, and overwrite protection.
The boundaries that matter
After actually running this pipeline, I do not think the dangerous parts live in the article body. They live in the boundaries. You think you are updating an old post, but you changed the slug. You think page properties hold the body, but the script never reads them. You assume Published means deployment is fully verified, while this version really means the content pipeline reached the production deploy trigger successfully. Or you assume automation removes the need for manual testing, but build failures still have to be debugged in the repo.
There is another common misunderstanding too. People hear “write in Notion and auto-publish to the blog” and assume that means they can format the page however they want and trust the pipeline to magically turn it into a clean post. That is not how I think about it anymore. In this setup, Notion is really acting like a more comfortable Markdown authoring surface. Once I started treating it that way, the whole workflow got better. I stopped caring about whether every block looked pretty inside Notion and cared more about whether the heading structure was clean, the code fences were correct, the images sat in the right places, and important links had their own lines.
That is why I still like a conservative debug path. If I need to test one page, I run the workflow manually, pass a specific page_id, and keep dry_run on first. That still pulls the Notion page, generates the English side, writes the temp output, and runs build validation, but it does not push or change production. It is much easier to trust the automation once I have already watched it pass through the risky middle layers.
If I had to reduce the working rules to a very small set, they would be these: write the body in the Notion page body, not in properties; give new posts their own slug when possible; reuse the original slug and explicitly allow updates when editing an old post; get the Chinese source into publishable shape first, then let automation generate English; and when a run fails, do not stare at the final exit 1, find the first step that actually broke.
That is really why I settled on this design. It is not the most ambitious blog architecture in the world. It is just a clean line between three things I wanted to keep: Notion as the writing surface, Astro as the file-first publishing target, and GitHub Actions as the layer that eats the repetitive work in the middle.
For me, that is much more useful than adding another admin button.