mulungood

Powering a blog with Notion through JSON files & NextJS

How to use Notion to power a NextJS blog, the benefits of using this approach, and why we chose it over another CMS.

Henrique Doro's photoHenrique Doro

When starting mulungood’s blog, our focus was on getting started as quickly as possible and focus on the writing, with minimal friction. As many out there, we opted for writing the content in Notion - which I’m using to write this very post -, and got the site up and running in 30 minutes after the publication of our first post.

This very post is hosted and powered by Notion

We could’ve used a paid-for service like Potion or Super, but as we wanted to eventually craft a complete website beyond a blog, we opted for starting with the NextJS-powered react-notion-x, a popular template built by Travis Fischer. It was quick to get started and allowed us to ship our homepage in React in an afternoon.

However, that project is stuck on Next 12, its SEO and performance qualities are dubious, and it was hard to customize the rendered article layout. This is a walk-through of how we’ve started accessing Notion’s API directly to feed our blog.

📌 We’re using Javascript/Typescript with NextJS v14, which will guide the examples in this post. The same principles apply to most modern JS meta-frameworks like Remix, SvelteJS, Nuxt, Astro and SolidStart.

At the time of this writing, Notion doesn’t have SDKs for languages other than Javascript

The high-level view

📌 In the following steps, I’m assuming you already have a NextJS installation. If not, refer to their installation guide.

1) Create an internal Notion integration

Create an internal integration and copy its secret key to your environment variables (.env or .env.local) as NOTION_AUTH_SECRET. This will allow us to connect to Notion’s API and fetch the

2) Structure the Notion database

There’s no prescription here, all you need is to have a Notion database that has a property which you use to filter published/unpublished posts. In our case, we’re using a “Published” property that is filled only by posts that should be public

After the above, copy the database’s ID (Notion docs on IDs) and store it in your .env as NOTION_DATABASE_ID. We also suggest locking the database to prevent someone from accidentally modifying properties and breaking the front-end.

3) Fetch pages from Notion

Common issues at this step:

4) Format page data

You can render pages’ blocks via JSON by writing custom React components. That’s the approach react-notion-x took and it works well, but is also a lot of work.

Instead, this guide will focus on converting the page’s body to markdown and rendering that with react-markdown for simplicity.

// src/notion/formatNotionPages.ts import { Client } from '@notionhq/client' import { BlockObjectResponse, PageObjectResponse, RichTextItemResponse, } from '@notionhq/client/build/src/api-endpoints' import fs from 'fs' import { NotionToMarkdown } from 'notion-to-md' import path from 'path' const PAGES_RAW_DIR_PATH = path.resolve('src/notion/posts-raw') const FORMATTED_PAGES_DIR_PATH = path.resolve('src/notion/posts-formatted') export const notionClient = new Client({ auth: process.env.NOTION_AUTH_SECRET }) /** NotionToMarkdown is what will convert blocks' JSON to a string of Markdown */ export const n2m = new NotionToMarkdown({ notionClient: notionClient, }) /** We can add custom transformers to it, such as rendering an image with a special URL scheme * This specific customization is allow us to render images from Notion's servers, * otherwise they simply won't load. */ n2m.setCustomTransformer('image', async (block) => { if (!('image' in block)) return '' const caption = (await notionRichTextToMarkdown(block.image.caption || [])) || ' ' if (block.image.type === 'file') { const url = `https://www.notion.so/image/${encodeURIComponent( block.image.file.url, )}?table=block&id=${block.id}&cache=v2&q=100` return `![${caption}](${url})` } return `![${caption}](${block.image.external.url})` }) export function notionRichTextToMarkdown(rich_text: RichTextItemResponse[]) { return n2m.blockToMarkdown({ object: 'block', type: 'paragraph', paragraph: { rich_text, color: 'default', }, has_children: false, archived: false, } as any) } // Format each page's JSON we've fetched from Notion into a NotionPost fs.readdirSync(PAGES_RAW_DIR_PATH).forEach(async (filePath) => { const page = JSON.parse( fs.readFileSync(path.join(PAGES_RAW_DIR_PATH, filePath), 'utf-8'), ) const formatted = await formatPage(page) // ⚠️⚠️ @TODO ⚠️⚠️ // Define the filtering behavior of your blog. In our case, posts need a published_at and a slug values. if ( !formatted.slug || !formatted.title || !formatted.body || !formatted.published_at ) return fs.writeFileSync( path.join(FORMATTED_PAGES_DIR_PATH, `${formatted.slug}.json`), JSON.stringify(formatted, null, 2), ) }) async function formatPage( page: PageObjectResponse & { blocks: BlockObjectResponse[] }, ) { const { properties = {} } = page return { id: page.id, title: await formatProperty(properties.Name), body: n2m.toMarkdownString(await n2m.blocksToMarkdown(page.blocks))?.parent, // ⚠️⚠️ @TODO ⚠️⚠️ // Define which data to format. // Aside from title & body, all other properties depend on your specific database set-up created_at: page.created_time, updated_at: page.last_edited_time, published_at: ((await formatProperty(properties.Published)) as any)?.start, tags: await formatProperty(properties.Tags), excerpt: await formatProperty(properties.Excerpt), authors: await formatProperty(properties.Authors), slug: await formatProperty(properties.slug), } as NotionPost } async function formatProperty( property: PageObjectResponse['properties'][string], ) { if (!property?.type) return undefined switch (property.type) { case 'people': return property.people.flatMap((person) => 'name' in person ? { name: person.name, id: person.id, avatar_url: person.avatar_url, } : [], ) case 'checkbox': return property.checkbox case 'date': return property.date || undefined case 'rich_text': case 'title': return await notionRichTextToMarkdown( 'rich_text' in property ? property.rich_text : property.title, ) case 'multi_select': return property.multi_select case 'select': return property.select default: return undefined } } // You should probably move these types to another file type Markdown = string export type NotionPost = { id: string created_at?: string updated_at?: string published_at: string title: string body: string slug: string featured?: boolean tags?: { name: string; id: string }[] excerpt?: Markdown authors?: Author[] prevPost?: NotionPost | null nextPost?: NotionPost | null } type Author = { name: string id: string avatar_url?: string }

Then, similarly to step 3), add the notion:format script to your package.json to run the file above and run npm run notion:format to check if all is working fine.

5) Render NextJS routes

To “query” posts, we’re using a rudimentary listing of all JSON files in the formatted posts folder:

// src/fetchPosts.ts import { promises as fs } from 'fs' import path from 'path' import { FORMATTED_PAGES_DIR_PATH } from './notion/notionUtils' import { NotionPost } from './types' export async function fetchPosts() { const postFiles = await fs.readdir(FORMATTED_PAGES_DIR_PATH) return ( await Promise.all( postFiles.map(async (file) => { const filePath = path.join(FORMATTED_PAGES_DIR_PATH, file) const fileContents = await fs.readFile(filePath, 'utf-8') return JSON.parse(fileContents) as NotionPost }), ) ).sort((a, b) => { return ( new Date(b.published_at).getTime() - new Date(a.published_at).getTime() ) }) } export async function fetchPost(slug: string) { const posts = await fetchPosts() const post = posts.find((post) => post.slug === slug) if (!post) return undefined return { ...post, prevPost: posts[posts.indexOf(post) - 1] || null, nextPost: posts[posts.indexOf(post) + 1] || null, } }

Then, we can set-up a NextJS route for rendering the post:

import PostPage from '@/components/PostPage/PostPage' import { fetchPost, fetchPosts } from '@/fetchPosts' // A small function that converts a relative path to an absolute URL import { pathToAbsUrl } from '@/utils/urls' import { Metadata } from 'next' import { notFound } from 'next/navigation' export type PostPageProps = { params: { slug: string[] | string } } // Optimizing the page for SEO with good metadata export async function generateMetadata({ params, }: PostPageProps): Promise<Metadata> { const slug = typeof params.slug === 'string' ? params.slug : params.slug.join('/') const post = await fetchPost(slug) return { title: post?.title, description: post?.excerpt || `Written by ${post?.authors?.map((author) => author.name).join(', ')}`, openGraph: { type: 'article', url: pathToAbsUrl(post?.slug), authors: post?.authors?.map((author) => author.name), locale: 'en', publishedTime: post?.published_at, modifiedTime: post?.updated_at, tags: post?.tags?.map((tag) => tag.name), }, alternates: { canonical: pathToAbsUrl(post?.slug) }, keywords: post?.tags?.map((tag) => tag.name), } } /** * Tell Next what article routes exist so it can pre-render them and avoid on-demand API calls. * * @docs https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#generating-static-params */ export async function generateStaticParams() { const posts = await fetchPosts() return posts.map((post) => ({ slug: post.slug, })) } export default async function PostRoute({ params }: PostPageProps) { const slug = typeof params.slug === 'string' ? params.slug : params.slug.join('/') const post = await fetchPost(slug) if (!post) return notFound() return <PostPage post={post} /> }

From the <PostPage> component, the interesting parts are how we render authors and the body:

import { NotionPost } from '@/types' import Image from 'next/image' import Markdown from 'react-markdown' import PostBody from './PostBody' export default function PostPage({ post }: { post: NotionPost }) { return ( <> {/* ... */} <div className="space-y-2 flex-1"> {(post.authors || []).map((author) => ( <div key={author.id} className="flex items-center gap-4"> {author.avatar_url && ( <Image src={author.avatar_url} alt={`${author.name}'s photo`} className="!w-11 !h-11 flex-[0_0_2.75rem] rounded-full object-cover" width={44} height={44} sizes="44px" /> )} <span>{author.name}</span> </div> ))} </div> {/* ... */} <PostBody body={post.body} /> </> ) } export default function PostBody({ body }: { body: string }) { return ( <Markdown components={{ // ... img(props) { const { // eslint-disable-next-line node, ...rest } = props return ( // @ts-expect-error not parsing `fill` properly <Image {...rest} fill={true} /> ) }, }} > {body} </Markdown> ) }

Our react-markdown instance is a little more involved as we’ve customized some elements, but notice how we can use next/image for optimizing Notion images. It makes for a good performance gain and also reduces the pressure on Notion’s API as we’re serving cached versions from our Next site’s server. If you choose to do so, you’ll need to add the following to your Next configuration:

// next.config.js const nextConfig = { // ... images: { remotePatterns: [ { protocol: 'https', hostname: 'www.notion.so', pathname: '/image/**', }, { protocol: 'https', hostname: 'notion.so', pathname: '/image/**', }, { protocol: 'https', hostname: 's3-us-west-2.amazonaws.com', pathname: '/public.notion-static.com/**', }, { protocol: 'https', hostname: 'lh3.googleusercontent.com', }, ], }, }

Given this article’s already quite lengthy, I’m skipping the details of the rest of the Next site like open graph image generation, building sitemaps, optimizing performance, etc. Feel free to reach us out at hello@mulungood.com if you have any questions 🙂

Why not a headless CMS?

At this point, I’ve worked with Sanity.io for 6 years - it’s a marvelous content platform / headless CMS that allow you to build any content structure and workflow your organization needs. I have plenty of templates and expertise on it - it’d have been faster to set our blog with Sanity than anything else -, so why not use it?

I’ve also tried BaseHub which seems to be at the middle between Notion and Sanity and offer a really compelling UX and set-up story. However, it’s fresh out of the oven and still buggy and unreliable.

Unexpected benefits