How to setup Notion as CMS for an Astro blog

Table of Contents

Want to use Notion as CMS for your Astro blog? This complete guide will show you how to set up Notion as a content management system for your Astro blog, so you never have to open VS Code just to write a post again.

Using Notion as CMS for an Astro blog changed everything about how I manage content. Here's why I decided to set up this Notion CMS solution for my blog:

  1. I needed to write and edit from my phone
  2. I wanted the ability to schedule posts
  3. 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 a simpler CMS solution.

How to Set Up Notion CMS for Your Astro Blog

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 that makes it easy to integrate Notion content into your Astro blog.

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 together.

This blog in Notion setup worked perfectly because local files can support custom components using mdx if I needed them in the future, while Notion serves as my primary CMS for most posts.

Keep in mind that you’d need to use the latest Astro version that supports content layer for this to work.

Setting Up Notion as Your Blog CMS

My Notion db for the blog CMS 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 the 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 Notion updates work

Now that my blog can get posts from Notion as a CMS, 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 through Notion.

Astro will only pull the Notion posts when I build the website. So there were a couple of options for this:

  1. Rebuild the blog often, maybe once every hour
  2. Setup a 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 a 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 are 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.

Conclusion

Setting up Notion as CMS for an Astro Notion blog is straightforward with the right tools. The notion-astro-loader plugin makes the integration seamless, and you can have your blog pulling content from Notion in under an hour.

Were you able to integrate Notion with your Astro blog? DM me on X or LinkedIn with a screenshot - would love to see it!