How I use Notion as a CMS for my website
Recently I officially moved all my website data to Notion instead of using markdown. In this post, I'll share how I use Notion as a CMS for my entire website. Let's see how I did it!
Why
Previously, I wrote case studies and blog posts on my website using markdown files with hashicorp/next-mdx-remote. Every time I wanted to write a new post, I had to touch the code and commit to GitHub. This was very time-consuming and made me much lazier about writing.
Meanwhile, I regularly use Notion for writing. Since Notion provides a free API, I thought why not use it as a CMS for my website—it would be more intuitive and convenient for drafting. Plus, by using Notion, I can add dynamic features like comments or reactions to posts instead of just static content like before.
Structure
My website uses Next JS and is deployed on Vercel, and I use Notion's JavaScript SDK to fetch data. All pages on the website are statically rendered, so loading speed is very fast. For comment and reaction features specifically, I'll create separate API routes to interact with Notion.
I'll create Notion databases as follows:
- Projects: contains portfolio projects.
- Blog: contains blog posts.
- Comments: contains website comments.
- Reactions: contains website reactions.

Blog database
export const getStaticPaths: GetStaticPaths = async () => {
const projects = await getNotionProjectsWithCache();
const paths = projects.map((project) => ({
params: { slug: project.slug },
}));
return { paths, fallback: false };
};export const getStaticProps: GetStaticProps = async ({ params }) => {
const slug = params?.slug as string;
// Get all posts from the Notion database
const projects = await getNotionProjects(isDevEnvironment);
// Find the post with a matching slug property
let project: NotionProject | undefined = projects.find(
(proj) => proj.slug === slug
);
if (!project) {
return {
notFound: true,
};
}
// Get the Notion page data and all child block data
const notionContent = await getNotionProjectContent(project.id);
return {
props: {
project: project,
projects: projects,
notionContent,
},
revalidate: 120,
};
};Challenges
- Issue #1: Notion's image and video links only last for one hour.
- Issue #2: The layout of my portfolio case studies is quite complex, and Notion's default blocks aren't enough to meet my needs.
Solving issue #1
I read posts by Cory (designer at Notion) and Jake (software engineer at Notion) about how they approached solving the asset hosting problem when using Notion as a CMS.
For assets, Cory outlined three approaches:
There are essentially three options:
- Use getServerSideProps instead of getStaticProps to ensure a new and valid image URL is returned on each page visit.
- Write a script that crawls all posts for image blocks, downloads the images, and places them in the /public directory.
- Use getStaticProps and incremental static regeneration to crawl an individual post’s blocks at request and upload the image assets to your own S3 bucket.
[...] I ended up going with option #3, but it was a lot of work to implement. Unless you want to burn 8 hours, I’d recommend going with getServerSideProps for now.
He went with the third approach, writing a script to upload assets to Amazon S3 during build. Meanwhile, Jake wrote an API route to fetch assets by block ID.
I chose to follow an approach similar to Cory's. However, I don't use Amazon S3 but instead use Cloudinary (because it's free 😅). For each image or video block in a post, I upload it to Cloudinary to get the asset link and the asset's width/height. Then I save all the asset information to the page's Assets property as JSON. When rendering a post, I look up the block ID in the Assets property to get the image link and dimensions.

Solving issue #2
For this issue, the solution wasn't too difficult. I referenced the approach from super.so. This platform uses callout blocks as flexible blocks to serve as containers. I take the callout content to determine which component to use for rendering the block. Meanwhile, the block content consists of child blocks. This approach is similar to shortcodes in WordPress.

Results
Each Notion page like this: Eware gets rendered as https://auduongtuan.com/project/eware.
This makes writing and presenting case studies or posts much more convenient. For each post, I don't have to access the code or measure image/video sizes while still maintaining lazy loading without shifts like when using markdown before.

I've made all the code for my website public on GitHub. It's a bit "messy" but hopefully useful to someone: https://github.com/auduongtuan/auduongtuan.com
Give your reaction
Or wanna share your thoughts?
Suggestion