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.
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
- Create an internal Notion integration
- Structure a Notion database with the properties you want
- Fetch pages from Notion and store them as JSON in the git repository
- Before compiling the site, format these JSON files according to what properties your pages have
- In a NextJS route, read from these files and render the page accordingly
📌 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
-
Create a Notion database
- It should have a property to filter published/unpublished posts
- In our case, we’re using a “Published” property that is filled only by posts that should be public
- And another for defining the relative path of the page (slug)
- It should have a property to filter published/unpublished posts
-
Ensure it has at least one page in it so we can properly test
-
Make it public by publishing it to the web in the “Share menu” (see below)
-
Share it with the internal integration created in step 1)
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
-
Install
@notionhq/client
in your project (npm install @notionhq/client
or similar) -
Create a
fetchFromNotion.ts
file (or.js
if using plain Javascript):// src/notion/fetchFromNotion.ts import { Client } from '@notionhq/client' import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints' import fs from 'fs' import path from 'path' // Directory where we'll store the raw data of the pages export const PAGES_RAW_DIR_PATH = path.resolve('src/notion/posts-raw') // Refer to step 2) above for how to get the database ID export const DATABASE_ID = process.env.NOTION_DATABASE_ID export const notionClient = new Client({ auth: process.env.NOTION_AUTH_SECRET }) export default async function fetchFromNotion() { const queryResult = await notionClient.databases.query({ database_id: DATABASE_ID, }) // For each page, get its children's content const pages = ( await Promise.all(queryResult.results.map(fetchPageBlocks)) ) // Exclude missing pages or those that failed fetching .flatMap((r) => r || []) if (!fs.existsSync(PAGES_RAW_DIR_PATH)) { fs.mkdirSync(PAGES_RAW_DIR_PATH, { recursive: true }) } // Finally, write the pages' JSON to the file system pages.forEach((page) => { fs.writeFileSync( path.join(PAGES_RAW_DIR_PATH, `${page.id}.json`), JSON.stringify(page, null, 2), ) }) } /** Fecthes a page's blocks from the API */ async function fetchPageBlocks(result: QueryDatabaseResponse['results'][number]) { if (result.object !== 'page' || !('properties' in result)) return undefined try { const blocks = await notionClient.blocks.children.list({ block_id: result.id, }) return { ...result, blocks: blocks.results, } } catch (error) { return undefined } } // FINALLY, run the function so we can call this file from tsx (see below) fetchFromNotion()
-
Install
tsx
as a devDependency (npm install tsx -D
) so we can run this file via a script inpackage.json
:// package.json { // ... "scripts": { "dev": "next dev", "build": "pnpm run notion:format && next build", "notion:fetch": "tsx src/notion/fetchFromNotion.ts", "notion:format": "tsx src/notion/formatNotionPages.ts", "postinstall": "pnpm run notion:fetch && pnpm run notion:format" } }
-
And run
npm run notion:fetch
and check if all is right
Common issues at this step:
- The database isn’t shared with the integration, so you won’t have access to its pages
- You’ve copied the wrong database ID
- There are no pages in the database
- You’ve forgotten to populate your
.env
withNOTION_DATABASE_ID
andNOTION_AUTH_SECRET
.- Add
console.log({ dbId: process.env.NOTION_DATABASE_ID, secret: process.env.NOTION_AUTH_SECRET })
to your code and check if the right values are coming through
- Add
- You have too much content and my tiny unoptimized script above is insufficient
- In that case… well, you need a smarter system 😬
- Reach out to hello@mulungood.com and perhaps we can help!
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.
- Install
notion-to-md
as a dependency - it’ll convert blocks to markdown - Adjust the code below to your liking and, importantly, define which data you’re formatting in
formatPage
based on your Notion database’s properties and how to filter blog posts
// 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?
- It’s a bit overkill for our little blog, which really only needs entries with titles and rich text
- We need agility to develop the collactive habit of writing in order to get the blog going. Sharing our ideas should be fun and light, and the structure of a CMS inhibts that.
- Most importantly, Sanity’s rich text editor (powered by the great PortableText) is clunky and slow, and makes jotting down ideas cumbersome and frustrating.
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
- Being able to comment on our writing and collaborate as a team like we would on any Notion document is invaluable
- We automatically get mini author profiles based on our Notion avatar & names
- We can re-use content and keep it in sync with Notion’s linked blocks, in a principle known as Transclusion which saves us time when sharing ideas across posts and avoids the confusion
- This is one of the biggest benefits of Structured Content, a principle I deeply enjoy and have exercised a lot with Sanity.io