DEV Community

Cover image for Build a blog powered by NextJS & Notion API
Solomon Antoine
Solomon Antoine

Posted on

Build a blog powered by NextJS & Notion API

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

Screen Shot 2022-01-23 at 16.41.42.png

You can duplicate the template here.

Grant Integration access to Blog

Click the share button and give your integration access.

Screen Shot 2022-01-20 at 21.48.19.png

Create Project

Create NextJS Application

$ npx create-next-app mysite --typescript
Enter fullscreen mode Exit fullscreen mode

Install TailwindCSS

npm install -D tailwindcss postcss autoprefixer @tailwindcss/typography
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

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')
    ],
}
Enter fullscreen mode Exit fullscreen mode

Add Tailwind CSS to Global.css file

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add .env file

Create a new file called .env.local with the following info:

NOTION_ACCESS_TOKEN=
NOTION_BLOG_DATABASE_ID=
Enter fullscreen mode Exit fullscreen mode

For the NOTION_ACCESS_TOKEN we can go to our integrate and copy the secret key

Screen Shot 2022-01-20 at 22.20.08.png

For the NOTION_BLOG_DATABASE_ID we can copy the uuid within the url

Screen Shot 2022-01-20 at 22.21.28.png

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Blog Card Component

Next, we want to create a component for a Blog card

First install dayjs for morphing dates

$ npm install dayjs
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then replace

<p>Blog Post Component Here: {post.title}</p>
Enter fullscreen mode Exit fullscreen mode

with

import BlogCard from "../components/BlogCard";

<BlogCard key={post.id} post={post}/>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Social

Twitter

Github

YouTube

Patreon

Top comments (3)

Collapse
 
digital_hub profile image
hub

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

AFFiNE live demo has been released, please click and have a try: affine.pro
github.com/toeverything/AFFiNE/
[quote]Before we tell you how to get started with AFFiNE, we'd like to shamelessly plug our awesome user and developer communities across official social platforms! Once you’re familiar with using the software, maybe you will share your wisdom with others and even consider joining the AFFiNE Ambassador program to help spread AFFiNE to the world.
Features
Privacy focussed — AFFiNE is built with your privacy in mind and is one of our key concerns. We want you to keep control of your data, allowing you to store it as you like, where you like while still being able to freely edit and view your data on-demand.
Offline-first - With your privacy in mind we also decided to go offline-first. This means that AFFiNE can be used offline, whether you want to view or edit, with support for conflict-free merging when you are back online.
Clean, intuitive design — With AFFiNE you can concentrate on editing with a clean and modern interface. Which is responsive, so it looks great on tablets too, and mobile support is coming in the future.
Seamless transitions — However you want your data displayed, whichever viewing mode you use, AFFiNE supports easy transitions to allow you to quickly and effortlessly view your data in the way you want.
Markdown support — When you write in AFFiNE you can use Markdown syntax which helps create an easier editing experience, that can be experienced with just a keyboard. And this allows you to export your data cleanly into Markdown.
Choice of multiple languages — Thanks to community contributions AFFiNE offers support for multiple languages. If you don't find your language or would like to suggest some changes we welcome your contributions.[/quote]

Collapse
 
milkshakegum profile image
Milk

Hi! The notion page doesn't have permissions to be accessed. :(
Image description

Collapse
 
solomon04 profile image
Solomon Antoine

fixed !