Want to setup Notion as CMS for your Astro blog? This guide is for you if you’re tired of opening VS Code every time you wanted to write something on your blog.
I wanted to set up Notion as the writing platform for this personal blog. I had three reasons.
- I needed to write and edit from my phone
- I wanted the ability to schedule posts
- I wanted to access the content using ai agents
While I can use local files even with agents, I didn’t like the aspect that I needed to open the code IDE every time I thought of writing something. I needed it to be simpler.

Using Notion with Astro blog using Cursor & Claude
The first thing I did was to open Cursor and ask what my options are before writing any code. I was really happy when Claude in Cursor searched and came up with notion-astro-loader
, which is a community plugin for Astro blogs.
When I saw the loader I knew I could be done with setting this up in less than thirty minutes, and I did.
When I first setup the Notion loader, it replaced the local markdown posts that I had, and started to only show the posts from Notion. But instead, I wanted it to show both the Notion and local markdown posts.
I wanted this setup because local files can support custom components using mdx if I needed them in the future.
Keep in mind that you’d need to use the latest Astro version that supports content layer for this to work.
Notion setup
My Notion db for the blog has these fields:
- Name
- Tags
- Status
- Date
- Description
- Slug
I only pull the posts that are in published status and has a current or past date using Notion loader’s filter options. You can see the code below to see how it works.
To actually get the Notion posts using api, I created a Notion integration in the Notion integrations page.
I took the Notion database ID from the url of the Notion database. You can see this by clicking share and copying the link. The part between notion.so and ?v=
is the id for the Notion database.
Then NOTION_TOKEN
and NOTION_DATABASE_ID
are added as environment variables and the Notion loader will do the rest.
My content.config.ts
looks like this:
import { defineCollection, z } from "astro:content";
import { glob } from 'astro/loaders';
import { notionLoader } from 'notion-astro-loader';
import {
notionPageSchema,
propertySchema,
transformedPropertySchema,
} from "notion-astro-loader/schemas";
// Base schema that both collections will use
const baseSchema = z.object({
title: z.string(),
description: z.string(),
date: z.date(),
draft: z.boolean().optional(),
tags: z.array(z.string()).optional(),
status: z.string().optional(),
slug: z.string().optional(),
});
// Local Markdown collection (anything under /src/content/blog)
const blog = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().optional(),
tags: z.array(z.string()).optional(),
status: z.string().optional(),
slug: z.string().optional(),
}),
});
// Notion collection (only if credentials are available)
const notionToken = import.meta.env.NOTION_TOKEN;
const notionDatabaseId = import.meta.env.NOTION_DATABASE_ID;
const hasNotionCredentials = notionToken && notionDatabaseId;
const notion = defineCollection({
loader: hasNotionCredentials
? notionLoader({
auth: notionToken,
database_id: notionDatabaseId,
filter: {
and: [
{
property: 'Status',
select: {
equals: 'Published'
}
},
{
property: 'Date',
date: {
on_or_before: new Date().toISOString()
}
}
]
}
})
: undefined, // No loader if credentials aren't available
schema: hasNotionCredentials
? notionPageSchema({
properties: z.object({
Name: transformedPropertySchema.title,
Status: propertySchema.select.optional(),
Date: propertySchema.date,
Tags: transformedPropertySchema.multi_select.optional(),
Description: transformedPropertySchema.rich_text.optional(),
Slug: transformedPropertySchema.rich_text,
}),
}).transform((data) => {
// Transform Notion data to match expected blog schema
const title = data.properties?.Name || "Untitled";
const description = data.properties?.Description || "";
const date = data.properties?.Date?.date?.start ? new Date(data.properties.Date.date.start) : new Date();
const tags = data.properties?.Tags || [];
const status = data.properties?.Status?.select?.name || "";
const slug = data.properties?.Slug || "";
return {
title,
description,
date,
tags,
status,
slug,
draft: status !== "Published",
...data
};
})
: baseSchema, // Fallback schema if no credentials
});
const products = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: "./src/content/products" }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
draft: z.boolean().optional(),
demoURL: z.string().optional(),
repoURL: z.string().optional(),
logo: z.string().optional(),
show: z.boolean().optional(),
}),
});
// Export both collections - notion will be empty if no credentials
export const collections = { blog, notion, products };
Things to keep in mind
There was a bug when I first setup the Notion loader. For some reason, when I changed the database column name to Title from Name, it was throwing some warnings. So I decided to keep the field name as "Name", which is the default.
I also added a slug column to my Notion db. Initially it was using the row id as the slug. So you'd need to see how the slug is setup in the blog page.
The current Notion loader also doesn’t support youtube embeds or video. I’d need to customize the loader itself for it to work, and it was out of scope for this project.
My workaround is to use the local markdown setup for posts that require embeds.
Deploying to Cloudflare Pages and making updates work
Now that my blog can get posts from Notion, I needed to make sure that when I create or update a post on Notion, it shows up on my website. I also wanted to schedule future posts.
Astro will only pull the Notion posts when I build the website. So there were couple of options for this:
- Rebuild the blog often, maybe once every hour
- Setup webhook so that any changes to the Notion database trigger a rebuild
I decided to deploy the Astro blog on cloudflare pages since they give unlimited bandwidth for free. The free plan is limited to 500 builds a month. This is a great limit, but if I setup to build every hour, I'll exhaust the limits before the month is over (720/month).
I also tested by setting up webhook on Notion database changes to rebuild my blog. I used deploy hooks to rebuild the blog.
But even then the builds were too often. Any time I wrote anything in a page, or create an idea page for a future post rebuilt the blog. This meant as I was writing a post, the blog was built multiple times every few minutes.
So instead, I decided to build the blog once a day if there any changes. I set up make.com to check daily to see if there are any updates to the Notion db, and if there are, rebuilt the blog using deploy hooks.
This does mean that any changes I make or any posts I schedule will only be updated once a day. But I was ok with that. If there were any urgent changes, I could manually build the blog anyway.
Were you able to integrate Notion with your blog? DM me on X or LinkedIn with a screenshot - would love to see it!