- Link to YouTube tutorial: https://youtu.be/LFRYYIoiIZg
- Blog Example: https://nextjs-notion-blog-chi.vercel.app/
Introduction
Notion has been a game changer when it comes to my personal life. It allows me to manage everything from documenting goals, to journaling my thoughts. Because of this, I figured I’d use Notion to power my personal blog over a tool like WordPress for the convenience of not having to ever leave Notion. In this tutorial, I will demonstrate how you can use the NotionAPI in conjunction with NextJS & TailwindCSS to power your blog.
Setup Notion
Make sure you have a Notion account, note you can use their free tier for this tutorial.
Create a Notion Integration
Go to https://www.notion.so/my-integrations and create a new internal integration
Create a Notion Database Page
You can duplicate the template here.
Grant Integration access to Blog
Click the share button and give your integration access.
Create Project
Create NextJS Application
$ npx create-next-app mysite --typescript
Install TailwindCSS
npm install -D tailwindcss postcss autoprefixer @tailwindcss/typography npx tailwindcss init -p
Setup Project
Edit Tailwind Config
Go to your tailwind.config.js
file and add the following:
module.exports = { content: [ "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, fontFamily: { sans: ["'Montserrat'"], mono: ["'Inconsolata'"] } }, plugins: [ require('@tailwindcss/typography') ], }
Add Tailwind CSS to Global.css file
@tailwind base; @tailwind components; @tailwind utilities;
Add Document.tsx
In order to make use of our custom fonts, we need to create a new file called pages/_document.tsx
with the following information
import Document, {Html, Head, Main, NextScript, DocumentContext} from 'next/document' class MyDocument extends Document { static async getInitialProps(ctx: DocumentContext) { const initialProps = await Document.getInitialProps(ctx) return {...initialProps} } render() { return ( <Html> <Head> <link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin={'true'}/> <link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@200;300;400;500;600;700;800;900&family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet"/> </Head> <body> <Main/> <NextScript/> </body> </Html> ) } } export default MyDocument
Add .env file
Create a new file called .env.local
with the following info:
NOTION_ACCESS_TOKEN= NOTION_BLOG_DATABASE_ID=
For the NOTION_ACCESS_TOKEN
we can go to our integrate and copy the secret key
For the NOTION_BLOG_DATABASE_ID
we can copy the uuid within the url
Add Types File
Create a new file called @types/schema.d.ts
and add the following:
export type Tag = { color: string id: string name: string } export type BlogPost = { id: string; slug: string; cover: string; title: string; tags: Tag[]; description: string; date: string }
Build the project
Install Notion Client & Markdown
We need to install the Notion Javascript client in order to get the blog data and a couple other packages for display purposes
npm install @notionhq/client notion-to-md react-markdown
Create Custom Notion Service
import {Client} from "@notionhq/client"; import {BlogPost, PostPage} from "../@types/schema"; import {NotionToMarkdown} from "notion-to-md"; export default class NotionService { client: Client n2m: NotionToMarkdown; constructor() { this.client = new Client({ auth: process.env.NOTION_ACCESS_TOKEN }); this.n2m = new NotionToMarkdown({ notionClient: this.client }); } async getPublishedBlogPosts(): Promise<BlogPost[]> { const database = process.env.NOTION_BLOG_DATABASE_ID ?? ''; // list blog posts const response = await this.client.databases.query({ database_id: database, filter: { property: 'Published', checkbox: { equals: true } }, sorts: [ { property: 'Updated', direction: 'descending' } ] }); return response.results.map(res => { return NotionService.pageToPostTransformer(res); }) } async getSingleBlogPost(slug: string): Promise<PostPage> { let post, markdown const database = process.env.NOTION_BLOG_DATABASE_ID ?? ''; // list of blog posts const response = await this.client.databases.query({ database_id: database, filter: { property: 'Slug', formula: { text: { equals: slug // slug } }, // add option for tags in the future }, sorts: [ { property: 'Updated', direction: 'descending' } ] }); if (!response.results[0]) { throw 'No results available' } // grab page from notion const page = response.results[0]; const mdBlocks = await this.n2m.pageToMarkdown(page.id) markdown = this.n2m.toMarkdownString(mdBlocks); post = NotionService.pageToPostTransformer(page); return { post, markdown } } private static pageToPostTransformer(page: any): BlogPost { let cover = page.cover; switch (cover) { case 'file': cover = page.cover.file break; case 'external': cover = page.cover.external.url; break; default: // Add default cover image if you want... cover = '' } return { id: page.id, cover: cover, title: page.properties.Name.title[0].plain_text, tags: page.properties.Tags.multi_select, description: page.properties.Description.rich_text[0].plain_text, date: page.properties.Updated.last_edited_time, slug: page.properties.Slug.formula.string } } }
Edit Index file
First we want to make use of the staticProps
method like so:
import {GetStaticProps, InferGetStaticPropsType} from "next"; import Head from "next/head"; import {BlogPost} from "../@types/schema"; import NotionService from "../services/notion-service"; export const getStaticProps: GetStaticProps = async (context) => { const notionService = new NotionService(); const posts = await notionService.getPublishedBlogPosts() return { props: { posts }, } } const Home = ({posts}: InferGetStaticPropsType<typeof getStaticProps>) => { const title = 'Test Blog'; const description = 'Welcome to my Notion Blog.' return ( <> <Head> <title>{title}</title> <meta name={"description"} title={"description"} content={description}/> <meta name={"og:title"} title={"og:title"} content={title}/> <meta name={"og:description"} title={"og:description"} content={title}/> </Head> <div className="min-h-screen"> <main className="max-w-5xl mx-auto relative"> <div className="h-full pt-4 pb-16 px-4 md:px-0 mx-auto"> <div className="flex items-center justify-center"> <h1 className="font-extrabold text-xl md:text-4xl text-black text-center">Notion + NextJS Sample Blog</h1> </div> <div className="mt-12 max-w-lg mx-auto grid gap-5 lg:grid-cols-2 lg:max-w-none"> {posts.map((post: BlogPost) => ( <p key={post.id}>Blog Post Component Here: {post.title}</p> ))} </div> </div> </main> </div> </> ) }; export default Home;
Blog Card Component
Next, we want to create a component for a Blog card
First install dayjs for morphing dates
$ npm install dayjs
the create a file components/BlogCard.tsx
import {FunctionComponent} from "react"; import Link from "next/link"; import {BlogPost} from "../@types/schema"; import dayjs from 'dayjs' type BlogCardProps = { post: BlogPost } const localizedFormat = require('dayjs/plugin/localizedFormat'); dayjs.extend(localizedFormat) const BlogCard: FunctionComponent<BlogCardProps> = ({post}) => { return ( <Link href={`/post/${post.slug}`}> <a className="transition duration-300 hover:scale-105"> <div key={post.title} className="flex flex-col rounded-xl shadow-lg overflow-hidden"> <div className="flex-shrink-0"> <img className="h-64 w-full object-fit" src={post.cover} alt="" /> </div> <div className="flex-1 bg-gray-50 pt-2 pb-6 px-4 flex flex-col justify-between"> <div className="flex-1"> <span className="block mt-2"> <h4 className="text-xs font-medium text-gray-600">{dayjs(post.date).format('LL')}</h4> </span> <span className="block mt-2"> <h3 className="text-xl font-semibold text-gray-900">{post.title}</h3> </span> <span className="block mt-2"> <p className="text-sm text-gray-600">{post.description}</p> </span> <span className="block mt-2 space-x-4"> { post.tags.map(tag => ( <span key={tag.id} className='bg-green-300 text-green-800 px-2 py-1 text-xs rounded-lg'> #{tag.name} </span> )) } </span> </div> </div> </div> </a> </Link> ); }; export default BlogCard;
Then replace
<p>Blog Post Component Here: {post.title}</p>
with
import BlogCard from "../components/BlogCard"; <BlogCard key={post.id} post={post}/>
in the index file.
Create Post File
Next, we want to create the page for displaying single blog posts, by making a file called post/[slug].tsx
where we will make us of dynamic parameters.
💡 We will be making use of both getStaticPaths
and getStaticProps
which means you will have to redeploy your site whenever you make a change in Notion since we are generating static paths.
import {GetStaticProps, InferGetStaticPropsType} from "next"; import ReactMarkdown from "react-markdown"; import Head from "next/head"; import NotionService from "../../services/notion-service"; const Post = ({markdown, post}: InferGetStaticPropsType<typeof getStaticProps>) => { return ( <> <Head> <title>{post.title}</title> <meta name={"description"} title={"description"} content={post.description}/> <meta name={"og:title"} title={"og:title"} content={post.title}/> <meta name={"og:description"} title={"og:description"} content={post.description}/> <meta name={"og:image"} title={"og:image"} content={post.cover}/> </Head> <div className="min-h-screen"> <main className="max-w-5xl mx-auto relative"> <div className="flex items-center justify-center"> <article className="prose"> <ReactMarkdown>{markdown}</ReactMarkdown> </article> </div> </main> </div> </> ) } export const getStaticProps: GetStaticProps = async (context) => { const notionService = new NotionService() // @ts-ignore const p = await notionService.getSingleBlogPost(context.params?.slug) if (!p) { throw '' } return { props: { markdown: p.markdown, post: p.post }, } } export async function getStaticPaths() { const notionService = new NotionService() const posts = await notionService.getPublishedBlogPosts() // Because we are generating static paths, you will have to redeploy your site whenever // you make a change in Notion. const paths = posts.map(post => { return `/post/${post.slug}` }) return { paths, fallback: false, } } export default Post;
Recap
In conclusion, Notion is powerful tool that you can use for replacing your CMS applications. If you found this tutorial useful, considering subscribing to my YouTube channel where I record
programming content on the regular or follow me on Twitter.
Top comments (3)
Hi there i love this thread.
btw - a new tool is here.
AFFiNE is the Next-Gen Knowledge Base to Replace Notion & Miro. Open-source, privacy-first, and always free. Built with Typescript/React/Rust
github.com/toeverything/AFFiNE
Hi! The notion page doesn't have permissions to be accessed. :(

fixed !