How to create a blog using NextJS and Notion API

A rough guideline on how to create a blog website using NextJs SSG with Notion API

Next.js Static Site Generation (SSG) optimizes web application performance by generating static HTML pages at build time. This approach, inherent in the Next.js framework, results in faster loading speeds and enhanced SEO. Pre-rendering pages enables Next.js to serve static content, reducing the reliance on server-side processing during runtime. This balance between dynamic content and optimal performance makes Next.js SSG an ideal choice for constructing modern, high-performing web applications, simplifying server infrastructure requirements in the process.

Notion is a versatile and collaborative productivity tool that combines note-taking, task management, and content creation in a unified platform. Known for its flexibility, users can create customizable pages, databases, and documents to suit their specific needs. With real-time collaboration and a user-friendly interface, Notion has become a popular choice for individuals and teams aiming to streamline work processes and boost productivity.

Create Project

Start off by running the following command to create a fresh new NextJS project.

npx create-next-app@latest

After that, choose the following options.

What is your project named? my-blog
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) No
Would you like to customize the default import alias (@/*)? Yes
What import alias would you like configured? @/*

In case if you are wondering,

  • TypeScript is a strongly typed programming language that builds on JavaScript. It helps to reduce the errors while developer codes along the way. Check out TypeScript to learn more.
  • Tailwind CSS is a utility-first CSS framework that has ready made class for developer to use. This saves the hassle of creating base CSS styling and improves development time. Check out Tailwind CSS to learn more.

Install @notionhq/client package

In this project, @notionhq/client will be used to call the Notion API. Run the following command to install @notionhq/client.

npm install @notionhq/client

Setup Notion Integration

how to create integration, get secret key

  • Go to https://developers.notion.com/
  • Click “View My Integration” button which is located on the right hand side
  • You will see a “+ New Integration” button, click it to create a new integration
  • After submitting basic info, you can get your secret key which will be used later on to connect to Notion API (Please keep it somewhere safe)

Setup Notion Database (Table)

Create a database table in your notion account. Here you can see an example table in the figure below. Note that slug will be used as the route parameter for each blog post.

Next, you may get the database ID from the URL. The structure of the URL is as below.

https://www.notion.so/{DATABASE_ID}?v=ouiansd7832r87gbr2ib9na9oudn

In addition, you will need connect your newly created database to your new integration. Click the “···” button located at the top right corner of notion web app. Then, click “+ Add Connection” button and choose your desire integration. Now your page (with the database) is connected.

Setup ENV

Create /env.d.ts in the project directory for typescript to know you have these variables available in the .env.local file. In this project, NOTION_TOKEN is use to store the secret key and NOTION_DATABSE_ID is use to store the database ID.

namespace NodeJS {
  interface ProcessEnv {
    NOTION_TOKEN: string;
    NOTION_DATABASE_ID: string;
  }
}

In .env.local,

NOTION_TOKEN={secret-your_integration_secret_key}
NOTION_DATABASE_ID={your_database_id}

Create the Blog Overview Page

Create Function to Fetch All Post

Here we have:

  • a type named Blog
  • a variable named “notion” to instantiate notion client
  • getBlogDetails() function is to process the blogs post details into desired object property format.
  • getAllPublished() function fetches all blog posts with the status of “Published” and sorted by Date in an descending order.
// src/lib/notion.ts
import { Client } from "@notionhq/client";

export interface Blog {
  id: string;
  title: string;
  tags: string[];
  description: string;
  date: string;
  slug: string;
}

const notion = new Client({
  auth: process.env.NOTION_TOKEN,
});

const getBlogDetails = (post: any): Blog => {
  const getTags = () => {
    return post.properties.Tags.multi_select.map((item: any) => item.name);
  };

  return {
    id: post.id,
    title: post.properties.Title.title[0].plain_text,
    tags: getTags(),
    description: post.properties.Description.rich_text[0].plain_text,
    date: post.properties.Date.date.start,
    slug: post.properties.Slug.rich_text[0].plain_text,
  };
};

export const getAllPublished = async () => {
  try {
    const response = await notion.databases.query({
      database_id: process.env.NOTION_DATABASE_ID,
      filter: {
        property: "Status",
        status: {
          equals: "Published",
        },
      },
      sorts: [
        {
          property: "Date",
          direction: "descending",
        },
      ],
    });

    return response.results.map((item) => getBlogDetails(item));
  } catch (error: any) {
    console.error(error);
    const errText = error.toString();
    console.log("error message: ", errText);
    return [];
  }
};

Create a date formatter utility function

// src/utils/dataFormatter.ts

const MONTHS = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

export const formatDate = (dateValue: string) => {
  const fullDate = new Date(dateValue);
  const year = fullDate.getFullYear();
  const month = MONTHS[fullDate.getMonth()];
  const day = fullDate.getDate();

  return `${month} ${day}, ${year}`;
};

Create Icon component

// src/components/icons/RightArrow.tsx
import { IconAttribute } from "@/types/IconAttribute";

export default function RightArrow({
  width,
  height,
  className = "",
}: IconAttribute) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={width}
      height={height}
      viewBox="0 0 24 24"
      className={className}
    >
      <path
        fill="currentColor"
        d="m14 18l-1.4-1.45L16.15 13H4v-2h12.15L12.6 7.45L14 6l6 6z"
      />
    </svg>
  );
}

Create Post Component

// src/components/Post.tsx

import Link from "next/link";
// components
import RightArrow from "./icons/RightArrow";
// lib
import { Blog } from "@/lib/notion";
// utils
import { formatDate } from "@/utils/dateFormatter";

export default function Post({
  posts,
  showTags = true,
}: {
  posts: Blog[];
  showTags?: Boolean;
}) {
  const Post = ({ post }: { post: Blog }) => {
    return (
      <li key={post.id}>
        <p className="blog-title">{post.title}</p>
        <p className="description">{post.description}</p>
        {showTags ? (
          <div className="tags">
            {post.tags.map((tag) => (
              <Link
                key={`${post.id}_${tag}`}
                href={`/tags/${tag}`}
                className="tag"
              >
                <span key={tag}>#{tag}</span>
              </Link>
            ))}
          </div>
        ) : null}
        <div className="date-link-container">
          <p>{formatDate(post.date)}</p>
          <Link href={`/blog/${post.slug}`} className="read-more-button">
            Read Post <RightArrow width="20" height="20" />
          </Link>
        </div>
      </li>
    );
  };

  return (
    <ul className="blog-post-list">
      {posts.map((item) => {
        return <Post key={item.id} post={item} />;
      })}
    </ul>
  );
}

Create Page

// src/pages/index.tsx

// component
import Post from "@/components/Post";
// lib
import { Blog, getAllPublished } from "@/lib/notion";

export const getStaticProps = async () => {
  const data = await getAllPublished();

  return {
    props: {
      posts: data ? data : [],
    },
  };
};

export default function Home({ posts }: { posts: Blog[] }) {
  return (
    <main className="blog-page-container">
      <h1 className="home-title">Blog Overview</h1>
      <p className="font-raleway mb-7">
        Some short description to describe your blog.
      </p>
      {posts && posts.length > 0 ? (
        <Post posts={posts} />
      ) : (
        "There is no blog post."
      )}
    </main>
  );
}

Create the Blog Post Page

Create function to fetch blog page blocks

Here we have two functions:

  • getPageBlocks() fetches the blocks of the blog post page
  • getBlogPageBySlug() fetches the blog post page details using slug as a parameter
// src/lib/notion.ts

import { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";

export const getPageBlocks = async (pageId: string) => {
  const response = await notion.blocks.children.list({ block_id: pageId });

  if (response.results.length > 0) {
    const results = response.results.reduce(
      (initialResult: any, currentResult) => {
        const item = currentResult as BlockObjectResponse;
        if (item.type === "bulleted_list_item") {
          if (
            initialResult[initialResult.length - 1].type === "bulleted_list"
          ) {
            initialResult[initialResult.length - 1].children.push(item);
          } else {
            initialResult.push({
              id: Math.floor(Math.random() * 100000).toString(),
              type: "bulleted_list",
              children: [item],
            });
          }
        } else if (item.type === "numbered_list_item") {
          if (
            initialResult[initialResult.length - 1].type === "numbered_list"
          ) {
            initialResult[initialResult.length - 1].children.push(item);
          } else {
            initialResult.push({
              id: Math.floor(Math.random() * 100000).toString(),
              type: "numbered_list",
              children: [item],
            });
          }
        } else initialResult.push(item);

        return initialResult;
      },
      []
    );

    return results;
  }
  return [];
};

export const getBlogPageBySlug = async (slug: string): Promise<Blog | {}> => {
  const response = await notion.databases.query({
    database_id: process.env.NOTION_DATABASE_ID,
    filter: {
      property: "Slug",
      formula: {
        string: {
          equals: slug,
        },
      },
    },
  });

  if (response.results.length) {
    return getBlogDetails(response.results[0]);
  }

  return {};
};

Create Components

// src/components/Text.tsx

import { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
import React from "react";

export default function Text({ values }: { values: any }) {
  if (!values) {
    return null;
  }
  const textBlocks = values.rich_text as RichTextItemResponse[];

  return textBlocks.map((block) => {
    const { bold, code, color, italic, strikethrough, underline } =
      block.annotations;

    if (!block.href) {
      return (
        <span
          key={block.plain_text}
          className={[
            bold ? "font-bold" : "",
            code
              ? "text-red-500 bg-gray-200 py-0.5 px-2 rounded"
              : "text-gray-700",
            italic ? "italic" : "",
            strikethrough ? "line-through" : "",
            underline ? "underline" : "",
          ].join(" ")}
          style={color !== "default" ? { color } : {}}
        >
          {block.plain_text}
        </span>
      );
    } else {
      return (
        <a
          key={block.plain_text}
          href={block.href}
          rel="noreferrer noopener"
          className="resource-link"
        >
          {block.plain_text ? block.plain_text : block.href}
        </a>
      );
    }
  });
}
// src/components/renderer.tsx

// components
import Text from "./Text";
// lib
import {
  BulletedListItemBlockObjectResponse,
  NumberedListItemBlockObjectResponse,
} from "@notionhq/client/build/src/api-endpoints";

/**
 * List of components to render
 * - text (paragraph, headings)
 * - list (ordered and non ordered)
 * - divider
 * - image
 * - code block
 * - videos
 * - link preview
 * - quote
 * @returns Component
 */
export default function renderBlock({ block }: { block: any }) {
  const { type, id } = block;

  const value = block[type];

  switch (type) {
    case "paragraph":
      return (
        <p key={id}>
          <Text values={value} />
        </p>
      );
    case "heading_1":
      return (
        <h1 key={id} className="blog-heading-1">
          <Text values={value} />
        </h1>
      );
    case "heading_2":
      return (
        <h2 key={id} className="blog-heading-2">
          <Text values={value} />
        </h2>
      );
    case "heading_3":
      return (
        <h3 key={id} className="blog-heading-3">
          <Text values={value} />
        </h3>
      );
    case "bulleted_list":
      return (
        <ul key={id} className="list-disc list-outside pl-7 space-y-1">
          {block.children.map((item: BulletedListItemBlockObjectResponse) => {
            return (
              <li key={item.id}>
                <Text values={item.bulleted_list_item} />
              </li>
            );
          })}
        </ul>
      );
    case "numbered_list":
      return (
        <ol key={id} className="list-decimal list-outside pl-7 space-y-1">
          {block.children.map((item: NumberedListItemBlockObjectResponse) => {
            return (
              <li key={item.id}>
                <Text values={item.numbered_list_item} />
              </li>
            );
          })}
        </ol>
      );
    case "divider":
      return <hr key={id} />;
    case "code":
      return (
        <pre key={id} className="code-block">
          <code>{value.rich_text[0].plain_text}</code>
        </pre>
      );
    case "bookmark":
      return (
        <a
          key={id}
          href={value.url}
          rel="noreferrer noopener"
          className="resource-link"
        >
          {value.url}
        </a>
      );
    case "image":
      const src =
        value.type === "external" ? value.external.url : value.file?.url;
      const caption = value.caption.length ? value.caption[0].plain_text : "";
      return (
        <figure key={id}>
          <img src={src} alt={caption} loading="lazy" />
        </figure>
      );
    case "video":
      return <video key={id} src={block.external.url}></video>;
    default:
      break;
  }
}
// src/components/icons/Home.tsx

import { IconAttribute } from "@/types/IconAttribute";

export function HomeIcon({ width, height, className = "" }: IconAttribute) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={width}
      height={height}
      viewBox="0 0 24 24"
      className={className}
    >
      <path
        fill="currentColor"
        d="M6 19h3v-6h6v6h3v-9l-6-4.5L6 10zm-2 2V9l8-6l8 6v12h-7v-6h-2v6zm8-8.75"
      />
    </svg>
  );
}

Create Page

// src/pages/blog/[slug].tsx

import Link from "next/link";
// components
import renderBlock from "@/components/renderer";
import { HomeIcon } from "@/components/icons/Home";
// lib
import {
  Blog,
  getAllPublished,
  getPageBlocks,
  getBlogPageBySlug,
} from "@/lib/notion";
import { BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints";
// utils
import { formatDate } from "@/utils/dateFormatter";

export const getStaticPaths = async () => {
  const database = await getAllPublished();
  const paths = database.map((post) => {
    return {
      params: {
        id: post.id,
        slug: post.slug,
      },
    };
  });

  return {
    paths,
    fallback: true,
  };
};

export const getStaticProps = async ({
  params,
}: {
  params: { slug: string };
}) => {
  const { slug } = params;
  const page = (await getBlogPageBySlug(slug)) as Blog;
  const blocks = await getPageBlocks(
    Object.keys(page).length > 0 && page.hasOwnProperty("id") ? page.id : ""
  );

  return {
    props: {
      page,
      blocks,
    },
  };
};

export default function Slug({
  page,
  blocks,
}: {
  page: Blog;
  blocks: BlockObjectResponse[];
}) {
  if (page && blocks) {
    return (
      <div className="blog-wrapper">
        <nav>
          <Link
            href="/"
            className="p-2 bg-gradient-to-br from-white to-slate-100 rounded-md shadow"
          >
            <HomeIcon width="28" height="28" />
          </Link>
        </nav>
        <article className="blog-details">
          <h1>{page.title}</h1>
          <p className="description">{page.description}</p>
          <p className="author-date-container">
            <span>by Po Yi Zhi</span> |{" "}
            <span>Posted on {formatDate(page.date)}</span>
          </p>
        </article>
        <article className="block-content">
          {blocks.map((block) => renderBlock({ block }))}
        </article>
      </div>
    );
  }

  return <div>Oops, it seems this blog post does not exist!</div>;
}

Things to consider…

  • Avoid accidentally spam the API as there is a request limit of 3 request per second
  • Changing of content requires redeployment. Why? Because is SSG. All contents are pre-rendered and it is serving as static contents.
  • This project is only for the use case of pages without any children pages.
  • Regarding on styling, you may refer to src/styles/gloabl.css in the source code.

Source Code

You may find the entire source code of this project in the link below.

https://github.com/eazypau/my-blog

Resources