Using Notion as a CMS with React and NextJS
If you’re like me, you’re always on the look-out for the next big app that helps with all things; productivity, note-taking, adhoc lists that you’ll never get through.
That’s where Notion finally comes in. After years of jumping between Evernote, Todoist and a plethora of others, Notion was the one for me. Then, when they released their API, I knew I had made the right choice, and I wanted to see if Notion could be used as a free CMS alternative.
Well, since you’re seeing this now - I finally got around to setting it up, and I want to give a breakdown of how I went about it.
Final Product
Everything in this post is a Notion Block that has been retrieved using their API, and rendered using a mixture of TailwindCSS and DaisyUI.
Prerequisites
- Have a notion account
- Have node and npm installed on your machine
- Create a Notion Integration for a simple page - Check out Notion's Guidance here
Tech Stack
This site is based off of the Create T3 App scaffolding tool. Is it overkill for how I’m currently using it? Absolutely! But, it forced me to make some good decisions in the end (typescript), and overtime I plan to utilise the rest of the features as part of this powerful tech stack because this site is my own sandbox playground.
Technologies
- NextJS
- TypeScript
- Tailwind CSS
- ESLint
- DaisyUI (themes and card components)
"dependencies": { "@notionhq/client": "^2.2.13", "next": "^13.4.19", "next-auth": "4.20.1", "react-syntax-highlighter": "^15.5.0" } "devDependencies": { "daisyui": "^3.7.4", "eslint": "^8.49.0", "eslint-config-next": "^13.4.19", }
Getting Started
Install Notion Package
pnpm i @notionhq/client
Expected File Structure
- Root Directory - lib - notion.ts - src - pages - Blog.tsx - posts - [id].tsx - types - notion_types.ts - next.config.mjs - .env
The Code
If you’re not using Create T3 App, the only thing you have to change here is to disregard the import for env.mjs
and import your env variables as you normally would.
lib/notion.ts
import { Client } from "@notionhq/client";
import { env } from "~/env.mjs";
// instantiate the client
const client = new Client({
auth: process.env.NOTION_KEY,
});
// get an individual post
async function post(id: string) {
const myPost = await client.pages.retrieve({
page_id: id,
});
return myPost;
}
// get all the posts
async function posts() {
const myPosts = await client.databases.query({
database_id: env.NOTION_BLOG_DATABASE,
});
return myPosts;
}
// get all the blocks that make up a single post
async function blocks(id: string) {
const blocks = await client.blocks.children.list({
block_id: id,
});
return blocks;
}
export { post, posts, blocks };
pages/Blog.tsx
Create a page to show all of the posts, and link to them using Notions ID for the post.
Here we import the get requests for the posts from the lib/notion file we’ve just created
We’re creating a next NextPage passing in the Props
The type for Post
hasn’t been created yet - if you don’t NEED this to be type safe, swap posts: [Post]
for posts:[any]
- it’ll save you a lot of hassle but it’ll be harder to follow these guidelines.
This is using a card design from DaisyUI, that can be changed - the important part is to map over the results, pass the index into the div key={index}
to loop through your posts and access the notion properties like this: {result.properties.Name.title[0].plain_text}
import { type NextPage } from "next";
import Head from "next/head";
import Link from 'next/link';
import Image from 'next/image';
import { posts } from '../../lib/notion'
import type { Post } from '../types/notion_types'
interface Props {
posts: [Post]
}
const Blog: NextPage<Props> = (props) => {
return (
<>
<section className="container px-5 py-10 mx-auto max-w-6xl">
<main>
<h1 className="p-2 font-bold text-center justify-evenly text-4xl text-primary">
Latest Posts
</h1>
<div className='flex flex-wrap justify-around p-10'>
{
props.posts.map((result, index) => {
return (
<>
<div key={index} className="card w-64 bg-base-100 shadow-xl m-1">
<figure>
<Link href={`/posts/${result.id}`}>
<Image src={result.cover.external.url} width={300} height={200} alt={''} />
</Link>
</figure>
<div className="card-body">
<h2 className="card-title text-md hover:text-accent hover:underline">
<Link href={`/posts/${result.id}`}>
{result.properties.Name.title[0].plain_text}
</Link>
</h2>
<p className='text-sm'>{result.properties.Description.rich_text[0].plain_text}</p>
<div className="card-actions justify-end pt-2">
{/* TODO: Adjust this so it will work for 'x' number of tags selected */}
{result.properties.Tags.multi_select && result.properties.Tags.multi_select.length >= 1 && (
<div className="badge badge-primary text-white">
{result.properties.Tags.multi_select[0]?.name}
</div>
)}
{result.properties.Tags.multi_select && result.properties.Tags.multi_select.length >= 2 && (
<div className="badge badge-secondary text-white">
{result.properties.Tags.multi_select[1]?.name}
</div>)}
</div>
</div>
</div>
</>
)})}
</div>
</main>
</section></>
);};
export async function getServerSideProps() {
// Get the posts
const { results } = await posts();
// Return the result
return {
props: { posts: results,}
}}
export default Blog;
posts/[id].tsx
Using NextJS, by declaring a page with the [id]
syntax, we can dynamically pass in values (the individual notion post) and use this file as a template to create the design of our blog page.
The main complexity here lies within const renderBlock = (block: BlockObject) => {...}
and const renderRichText = (paragraph: RichTextArr) => {...}
If you don’t care for images, skip the renderImageBlock function
import type { GetStaticProps, NextPage, GetStaticPaths } from 'next';
import Image from 'next/image';
import Head from 'next/head';
import type { ParsedUrlQuery } from 'querystring';
import { post, posts, blocks } from '../../../lib/notion';
import SyntaxHighlighter from 'react-syntax-highlighter';
import { atomOneDark } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
import type { BlockObject, RichTextArr, ImageBlock, BlogPostProps } from '../../types/notion_types'
import { ImageEnum } from '../../types/notion_types'
import { motion, useScroll, useSpring } from "framer-motion";
// Get a single post
export const getStaticProps: GetStaticProps = async (ctx) => {
const { id } = ctx.params as IParams;
// Get the dynamic id
const page_result = await post(id);
const { results } = await blocks(id);
// Get the children
return {
props: {
id,
post: page_result,
blocks: results
}
}
}
// Get all posts
export const getStaticPaths: GetStaticPaths = async () => {
const { results } = await posts();
return {
paths: results.map((post) => {
// Go through every post
return {
params: {
// set a params object with an id in it
id: post.id
}
}
}),
fallback: false
}
}
const renderBlock = (block: BlockObject) => {
const { type } = block;
switch (type) {
case 'heading_1': {
const text = block.heading_1.rich_text[0].plain_text;
return (
<h1 className="...">
{text}
</h1>
);
}
case 'image': {
const content = renderImageBlock(block.image)
return (
<div className='...'>
<div className='...'>
{content}
</div>
</div>
)}
case 'paragraph': {
const content = renderRichText(block.paragraph);
return <p className='font-reading my-3 line px-4 leading-6'>{content}</p>;
}
default:
console.log(block)
}
};
const renderRichText = (paragraph: RichTextArr) => {
return (
<>
{paragraph.rich_text.map((text, index) => (
<span
key={index}
className={`
${text.annotations.bold ? 'font-bold' : 'font-normal'}
${text.annotations.italic ? 'italic' : 'not-italic'}
${text.annotations.strikethrough ? 'line-through' : ''}
${text.annotations.underline ? 'underline' : ''}
${text.annotations.code ? '...' : ''}`}>
{text.href ? (
<a className='text-accent underline font-semibold hover:text-secondary'
href={text.href}
target="_blank">{text.plain_text}
</a>
) : (
text.plain_text
)}
</span>
))}
</>
);
}
const Post: NextPage<BlogPostProps> = ({ post, blocks }) => {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
});
const title = `BC | ${post.properties.Name.title[0].plain_text}`
return (
<div className='mx-auto bg-slate-600'>
<div className="...">
<Head>
<title>
{title}
</title>
<link rel="icon" href={"/favicon.png"} />
</Head>
<motion.div className="progress-bar" style={{ scaleX }} />
{
blocks.map((block, index) => {
return (
<div key={index}>
{
renderBlock(block)
}
</div>
)
})
}
</div>
</div>
)}
export default Post;
Making it all TypeScripty
This file is a beast, and I am still learning TypeScript. So whilst I’ve tried to make parts of it reusable such as RichTextArr and not all of it is used in the code base, like MultiSelect - it is great to have this when it came to IDE prompting.
Check out the full file on my GitHub Gist: NotionTypes.ts
export type { Post, BlockObject, BlogPostProps, RichText, ImageBlock, RichTextArr, };
Constraints (How janky is it?)
First things first, notion was not intended to be used this way. It’s hacky, but it works - and that’s the fun of it.
Images
Notion doesn’t allow you to dynamically set image sizes - you can either set all images in your posts to the same size OR, I used the caption as a way to pass in aspect ratios inside the renderImageBlock function.
Block Types
Everything in Notion is a block, which means - if you haven’t accounted for a style within that - then you don’t render it. For example, bold, underline, italic is all taken care of, and bullet points are fine… but I haven’t implemented styling to render nested bullet points yet.
NextJS 13
If you are failing to connect to notion, or the images from unsplash aren’t working then check your config file. In this case, I had to edit next.config.mjs
If you upload a file into your blog, Notion uses AWS S3 buckets to store these so you need to add that too. My region was us-west-2 but yours may differ.
images: { remotePatterns: [ { protocol: 'https', hostname: 'www.notion.so', port: '', pathname: '/images/**', }, { protocol: 'https', hostname: 'images.unsplash.com', port: '', pathname: '/**', }, { protocol: 'https', hostname: 's3.us-west-2.amazonaws.com', port: '', pathname: '/**', } ] }
Conclusion
If you like notion, then it’s a fun and interesting exercise to get into setting up their API and using NotionSDK.
It gives you unlimited flexibility to render items as you want, but it does come at a cost, and that can’t be understated.
Next time I'd like to repeat this prototype using NextJS 14 and it's new app router.
If you have any questions, please feel free to get in touch.