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
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.
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,
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
how to create integration, get secret key
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.
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}
Here we have:
// 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 [];
}
};
// 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}`;
};
// 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>
);
}
// 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>
);
}
// 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>
);
}
Here we have two functions:
// 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 {};
};
// 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>
);
}
// 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>;
}
You may find the entire source code of this project in the link below.
https://github.com/eazypau/my-blog