Building a Developer Portfolio: Creating a NextJS blog in typescript using Notion APIPublished on Mar 28, 2022Updated on Oct 20, 2022Read on dev.to

Estimated reading time : 25 minutes

Part 2 of the series breaking down my portfolio/blog development. Here I’ll break down on how I am using Notion as an headless CMS.

nextjsportfolionotiontypescript

Prerequisites

This article is a follow-up to my last article, which covered how to set up a NextJS repository for your developer portfolio. In this article, I will cover how I used Notion as a headless CMS for my blog content.
It is expected that you know
  • How to create react components
  • how to use dynamic routing in NextJS
  • static site generation in NextJS with getStaticProps and getStaticPaths.
  • typescript
You can find the source code here.

Do you need a CMS?

In my last post, I explained how NextJS has MDX support, and as developers, we are used to writing in markdown. So most developers might prefer to use MDX with their blog, which would be a much simpler solution than integrating with a CMS. Then why did I choose to use Notion as a CMS? Primarily because I use Notion daily to manage my study notes, work tracker, travel planner etc. So it made sense to store my blogs in Notion as well. There are other benefits to using the Notion API as a headless CMS.
Having your code lie separately from your data gives you more flexibility. I can edit and manage my posts from the Notion website or the mobile app without having to make commits or pull requests. It creates a cleaner repository where your commit history isn’t swamped with commits made to correct grammatical mistakes and update content. The Notion application acts as a dashboard for me to manage my content, and the website becomes the outlet where it is presented to the users. It also handles the issue of storing static assets, as you can upload your pictures and videos to notion and then retrieve your content from there instead of putting all your static files in your /public directory.

Setting up Notion

First, you would need a Notion account. Sign up at notion.so and create your workspace. After that you would require a Notion database for storing and fetching blog articles from. You can duplicate the template I have used if you want to (this guide would follow this template). Just open the template and click on duplicate.
In the template that I made, I have the following columns
Title : title Tags : multi_select Publish : check_box Date: last_edited_time Description : rich_text Link : url PublishDate : Date
  • The title is the page.
  • The tags is a multi_select that allows us to add tags to our blog post.
  • Publish is a checkbox that controls whether this page is a draft or a published article on the site.
  • Date stores the last_edited_time to track when an article was last updated.
  • The description is a rich_text summary.
  • Link is a url to another other sites where the article was published.
  • PublishDate is the date on which it was published.
Now, you have a Notion database to store your blog articles with a dummy article. Now you need to create an integration. For that, go to https://www.notion.com/my-integrations and create a new integration. Give it a name and read capabilities with the workspace you have created. Copy the token and save it somewhere securely. Next, go to your database and click on share. Now you add your integration name here and copy the database ID.
https://www.notion.so/a8aec43384f447ed84390e8e42c2e089?v=... |--------- Database ID --------|
Store your Notion integration token and your database ID in the .env file inside your directory. Do not push this file to GitHub.
NOTION_KEY=<YOUR NOTION INTEGRATION TOKEN> NOTION_BLOG_DATABASE_ID=<YOUR NOTION BLOG DATABASE ID>
You are now all set. Follow the getting started documentation provided by notion for more details.

Retrieving data from Notion API

Go to your NextJS directory and install the notion client.
$ npm install @notionhq/client
💡
There are a lot of unofficial open-source notion integration libraries out there you should check them out to see if they meet your requirements or not before making your own integration. I might separate the code I wrote to integrate the notion of creating React components into a separate library for others to use shortly.
Let’s look at the sample code of the two API calls we’ll be using. The first is to query the database to collect all blog article data.
import { Client } from "@notionhq/client"; const notion = new Client({ auth: process.env.NOTION_KEY }); export async function getBlogPosts(){ const databaseId = process.env.NOTION_BLOG_DATABASE_ID; const response: QueryDatabaseResponse = await notion.databases.query({ database_id: databaseId, }); return response; }
Retrieving the page data is slightly different. Notion stores its page data in the form of blocks. So to get the content of a page, you need to retrieve the blocks. Here is how you would retrieve the blocks on a page.
export const getBlocks = async (id: string) => { let response = await notion.blocks.children.list({ block_id: id }); return response; };
When you retrieve the blocks for a page, you only get one level of blocks. So you’ll have to send subsequent requests for every block to retrieve any children if the block has any children.

Typing Issues

If you’re not using typescript then the you don’t have to worry about the typing issue.
When using the notion API in typescript, you’ll find it difficult to use the typing provided as Notion auto-generates the typing, leading to a large union of types aggregated in a few types. When you want a specific property or block type definition, this poses a problem. You don’t have a type defined for them, as those definitions are part of a very large union (which isn’t easily readable either). This is not ergonomic to work with. You can learn more about this issue here.
You could just use any type, but that isn’t a recommended practice. A better workaround would be to use the extract utility type. The extract type is a generic type that will help us extract the specific type we need from a union of types.

Getting all blog posts from Notion database

Let's look at our blog database query response. If you print the query database response on the console, you would get something like this.
{ object: 'list', results: [ { object: 'page', id: '270434234-31fc-4193-86e2-5ebd7f0de8de', created_time: '2022-02-18T18:27:00.000Z', last_edited_time: '2022-03-25T17:44:00.000Z', created_by: [Object], last_edited_by: [Object], cover: [Object], icon: null, parent: [Object], archived: false, properties: [Object], url: 'https://www.notion.so/TestPage-270bd3023413c419386e25ebd7f0de8de' } ], next_cursor: null, has_more: false, type: 'page', page: {} }
The results member of the QueryDatabaseResponse object holds the database entries. The database entry would consist of a properties object which holds the data stored in each column of your database table.
If you look at the type definition of the response.results on your IDE in the tooltip, you would see that it is a very large union of type definitions. Similarly, the type definition for response.results.[*].properties is an even bigger union of type definitions. Using extract, we can get the exact type definition we need from the union and give it an alias. Having these aliases will allow you to safely extract information from your query database response and store them in an object which you can use more easily.
import { QueryDatabaseResponse, } from "@notionhq/client/build/src/api-endpoints"; export type PostResult = Extract< QueryDatabaseResponse["results"][number], { properties: Record<string, unknown> } >;
Now, PostResult type is an alias to the type definitions in response.results with a properties attribute. We can then extract the type definitions for the specific property types used in our columns using extract as well.
import { QueryDatabaseResponse, } from "@notionhq/client/build/src/api-endpoints"; export type PostResult = Extract< QueryDatabaseResponse["results"][number], { properties: Record<string, unknown> } >; type PropertyValueMap = PostResult["properties"]; type PropertyValue = PropertyValueMap[string]; type PropertyValueType = PropertyValue["type"]; type ExtractedPropertyValue<TType extends PropertyValueType> = Extract< PropertyValue, { type: TType } >; export type PropertyValueTitle = ExtractedPropertyValue<"title">; export type PropertyValueRichText = ExtractedPropertyValue<"rich_text">; export type PropertyValueMultiSelect = ExtractedPropertyValue<"multi_select">; export type PropertyValueUrl = ExtractedPropertyValue<"url">; export type PropertyValueDate = ExtractedPropertyValue<"date">; export type PropertyValueEditedTime = ExtractedPropertyValue<"last_edited_time">;
Now let’s define an interface for our post data which we would require.
export interface IPost { id: string; url: string; tags: string[]; modifiedDate: string; publishDate: string; title: string; description: string; link?: string; }
Now, we’ll extract an array of IPost from the QueryDatabaseResponse.
type DatabaseItem = PostResult & { properties: { Title: PropertyValueTitle; Date: PropertyValueEditedTime; Tags: PropertyValueMultiSelect; Description: PropertyValueRichText; Link: PropertyValueUrl; PublishDate: PropertyValueDate; LastUpdated?: PropertyValueDate; }; }; const extractPosts = async ( response: QueryDatabaseResponse, ): Promise<IPost[]> => { const databaseItems: DatabaseItem[] = response.results.map( (databaseItem) => databaseItem as DatabaseItem, ); const posts: IPost[] = await Promise.all( databaseItems.map(async (postInDB: DatabaseItem) => { const title = postInDB.properties.Title.title[0].plain_text; const date = postInDB.properties.Date.last_edited_time; const description = postInDB.properties.Description.rich_text[0].plain_text; const url = getCanonicalURL(title); const link = postInDB.properties.Link.url || ""; const tags = postInDB.properties.Tags.multi_select; const cover = await getPageCover(postInDB.id); const publishdate = postInDB.properties.PublishDate.date?.start; const post: IPost = { id: postInDB.id, title: title, modifiedDate: date, description: description, url: url, link: link, cover: cover, tags: tags, publishDate: publishdate || date, }; return post; }), ); return posts; }; export async function getBlogPosts(): Promise<IPost[]> { const databaseId = process.env.NOTION_BLOG_DATABASE_ID || ""; const response: QueryDatabaseResponse = await notion.databases.query({ database_id: databaseId, }); console.log(response); const posts = await extractPosts(response); return posts; }
The property types we created previously using extract help us get the information we require from the QueryDatabaseResponse without having to deal with possible undefined fields. Now, the getBlogPosts function returns an array of IPost, which is much easier to work with.
The getCanonicalURL function creates a URL for the blog post based on its title.
export const getCanonicalURL = (title: string): string => { const cleaned = title.replace(/\W/gm, " "); const removedSpaces = cleaned .split(" ") .filter((str) => str) .join("-"); return removedSpaces; };

Getting all blocks of a page

Now that we have the ID of all our blog pages. We can retrieve the blocks for each page. Let’s look at the ListBlockChildrenResponse we get when retrieving the blocks.
{ object: 'list', results: [ { object: 'block', id: 'a6fc6649-1a48-4be7-9772-f945780b09fe', created_time: '2022-02-19T08:11:00.000Z', last_edited_time: '2022-03-25T17:41:00.000Z', created_by: [Object], last_edited_by: [Object], has_children: false, archived: false, type: 'bookmark', bookmark: [Object] }, ... // Truncated { object: 'block', id: '191d3863-cd7b-45ca-8b82-83c968b5be3a', created_time: '2022-03-25T17:44:00.000Z', last_edited_time: '2022-03-25T17:44:00.000Z', created_by: [Object], last_edited_by: [Object], has_children: false, archived: false, type: 'paragraph', paragraph: [Object] } ], next_cursor: null, has_more: false, type: 'block', block: {} }
  1. You only get one level of blocks when you retrieve the blocks of a page. If one block has child blocks, you’ll have to call the function again with the block ID to get its children. You can know if a block has children by seeing the value of has_children.
  1. Depending on the block type, the object will have different members. For "paragraph” type blocks, the information about the block is stored in paragraph members and so on for all the block types offered by Notion. Again, the type definitions for these are not properly defined, as everything inside ListBlockChildrenResponse is defined as a union of type definitions.
So to properly extract information from the blocks, we’ll again use the Extract utility class to extract the block type definitions.
export type Block = Extract< ListBlockChildrenResponse["results"][number], { type: string } >; export type BlockType = Block["type"]; type ExtractedBlockType<TType extends BlockType> = Extract< Block, { type: TType } >; export type ParagraphBlock = ExtractedBlockType<"paragraph">; export type HeadingOneBlock = ExtractedBlockType<"heading_1">; export type HeadingTwoBlock = ExtractedBlockType<"heading_2">; export type HeadingThreeBlock = ExtractedBlockType<"heading_3">; export type HeadingBlock = | HeadingOneBlock | HeadingTwoBlock | HeadingThreeBlock; export type BulletedListItemBlock = ExtractedBlockType<"bulleted_list_item">; export type NumberedListItemBlock = ExtractedBlockType<"numbered_list_item">; export type QuoteBlock = ExtractedBlockType<"quote">; export type EquationBlock = ExtractedBlockType<"equation">; export type CodeBlock = ExtractedBlockType<"code">; export type CalloutBlock = ExtractedBlockType<"callout">; export type ToggleBlock = ExtractedBlockType<"toggle">; export type EmbedBlock = ExtractedBlockType<"embed">; export type WebBookmarkBlock = ExtractedBlockType<"bookmark">; export type ImageBlock = ExtractedBlockType<"image">;
Notion uses the same definition for rich text and file objects, so we can create aliases for that for reusability.
export type RichText = ParagraphBlock["paragraph"]["rich_text"][number]; export type File = ImageBlock["image"];
As we have seen when we printed ListBlockChildrenResponse, the Block type we extracted doesn’t have an attribute to store children in it. But it would be better for us if we can store the children of the block inside the block object itself. So we define a new type that extends the extracted Block type.
export type BlockWithChildren = Block & { type: BlockType; childblocks: BlockWithChildren[]; }
Now to retrieve all the blocks inside the page.
export const getBlocks = async (blockId: string): Promise<Block[]> => { const blocks: Block[] = []; let response = await notion.blocks.children.list({ block_id: blockId, }); response.results.map((block) => { blocks.push(block as Block); }); return blocks; };
The max number of blocks you can get per request is 100, so you’ll have to utilize pagination to get all the blocks if they exceed 100.
export const getBlocks = async (blockId: string): Promise<Block[]> => { const blocks: Block[] = []; let response = await notion.blocks.children.list({ block_id: blockId, page_size: 25, }); response.results.map((block) => { blocks.push(block as Block); }); while (response.has_more && response.next_cursor) { response = await notion.blocks.children.list({ block_id: blockId, page_size: 25, start_cursor: response.next_cursor, }); response.results.map((block) => { blocks.push(block as Block); }); } return blocks; };
Now we also need a function to get the children of the block if the block has children and convert the Block object into a BlockWithChildren object.
const getChildren = async (block: Block): Promise<BlockWithChildren> => { const children: BlockWithChildren[] = []; if (block.has_children) { const childBlocks = await getBlocks(block.id); const childBlocksWithChildren = await Promise.all( childBlocks.map(async (block) => await getChildren(block)), ); childBlocksWithChildren.map((block: BlockWithChildren) => { children.push(block); }); } const ablock: BlockWithChildren = { ...block, childblocks: children, }; return ablock; };
The getChildren method takes a Block, recursively retrieves the children for the block if it has any, and returns a BlockWithChildren. Now adding all of it together, I have created a getPageBlocks method which will return an array of BlockWithChildren having all the blocks of the page.
export const getBlocks = async (blockId: string): Promise<Block[]> => { const blocks: Block[] = []; let response = await notion.blocks.children.list({ block_id: blockId, page_size: 25, }); response.results.map((block) => { blocks.push(block as Block); }); while (response.has_more && response.next_cursor) { response = await notion.blocks.children.list({ block_id: blockId, page_size: 25, start_cursor: response.next_cursor, }); response.results.map((block) => { blocks.push(block as Block); }); } return blocks; }; const getChildren = async (block: Block): Promise<BlockWithChildren> => { const children: BlockWithChildren[] = []; if (block.has_children) { const childBlocks = await getBlocks(block.id); const childBlocksWithChildren = await Promise.all( childBlocks.map(async (block) => await getChildren(block)), ); childBlocksWithChildren.map((block: BlockWithChildren) => { children.push(block); }); } const ablock: BlockWithChildren = { ...block, childblocks: children, }; return ablock; }; export const getPostBlocks = async ( pageId: string, ): Promise<BlockWithChildren[]> => { const blocks: Block[] = await getBlocks(pageId); const blocksWithChildren: BlockWithChildren[] = await Promise.all( blocks.map(async (block: Block) => { const blockWithChildren = await getChildren(block); return blockWithChildren; }), ); return blocksWithChildren; };
The getBlogPosts function and the getPageBlocks function should be called in the getStaticProps method of your page. The page will be built at runtime, so you don’t have to worry about your site making repeated requests to your notion API each time the user requests the page. With ISR, you can ensure your pages are up to date with the content inside Notion by rebuilding the pages after a certain time.

Rendering Page Content

Now that we have an array of BlockWithChildren, we can just iterate through the array and return a react component based on the block type. We can similarly render the children of the block inside that react component.
const renderBlock = (block: BlockWithChildren): React.ReactNode => { const childblocks: BlockWithChildren[] = block.has_children ? block.childblocks : []; const content: React.ReactNode = childblocks.map( (block: BlockWithChildren) => { return renderBlock(block); }, ); switch (block.type) { case "paragraph": return <Paragraph key={block.id} {...block} />; case "heading_1": return <Heading1 key={block.id} {...block} />; /* Truncated code for readability */ default: // to handle unsupported block by our integration return <NotSupportedBlock key={block.id} reason={block.type} />; } }; export type PostContentProps = { blocks: Array<BlockWithChildren>; }; export const PostContent: React.FC<PostContentProps> = ({ blocks, }: PostContentProps) => { return ( <article> {blocks.map((block: BlockWithChildren) => { return renderBlock(block); })} </article> ); };
And then, inside our page, we can use the PostContent component.
<PostContent blocks={blocks} />
Now let’s look at how we handle the common blocks.

Text Blocks

When I mean text blocks, I refer to paragraphs, headings, callouts and quotes. These blocks have rich text objects that are presented in different ways on the front end. So all we have to do is make a function to render the rich text and present them inside the react components we make for these blocks. If you look at the type definitions for these block types, you’ll notice they have an array of RichText stored in the rich_text member. We’ll take this array and return a span for each RichText. The text content of a RichText object is stored in the plain_text member. RichText can be bold, italic, code, strikethrough, underlined, links, different colours etc, so we’ll have to add that in the styling of the span.
export const renderText = ( id: string, textBlocks?: Array<RichText>, ): React.ReactNode => { if (!textBlocks) { return <></>; } let count = 0; return textBlocks.map(({ annotations, plain_text, href }) => { const { bold, code, color, italic, strikethrough, underline } = annotations; count = count + 1; return ( <span key={`text-${id}-${count}`} className={[ bold ? "bold" : "", code ? "mono" : "", italic ? "italic" : "", strikethrough ? "strikethrough" : "", underline ? "underline" : "", ].join(" ")} style={color !== "default" ? { color } : {}} > {href ? ( <a className="default-link not-prose" href={href}> {plain_text} </a> ) : ( plain_text )} </span> ); }); };
Based on that, the react component for paragraph-type blocks would look like
type ParagraphBlockProps = PropsWithRef<ParagraphBlock>; export const Paragraph: React.FC<ParagraphBlockProps> = ({ id, paragraph, }: ParagraphBlockProps) => { return <p>{renderText(id, paragraph.rich_text)}</p>; };

List Blocks

List blocks are more complicated to handle as Notion treats lists similar to how markdown handles lists. They do not follow a nested structure.
- Item 1 - SubItem 1 - SubItem 2 - Item 2 - SubItem 3 - SubItem4
Meanwhile in HTML, this would be represented differently
<ul> <li> Item 1 <ul> <li> SubItem 1 </li> <li> SubItem 2 </li> </ul> </li> <li> Item 2 <ul> <li> SubItem 3 <ul> <li> SubItem 4 </li> </ul> </li> </ul> </li> </ul>
In HTML, the list items needs to be nested inside a <ul> or <ol> tag. When we get the bulleted_list_item or the ordered_list_item type of block, they don’t have any data indicating whether they belong to the same list. So we need to pre-process the list items from Notion to create the nested structure of lists. My approach has been to create my own ListBlock type, which I extend the extracted BlockWithChildren type definition.
export type ListBlock = { id: string; object: string; type: "bulleted_list" | "numbered_list"; childblocks: BlockWithChildren[]; has_children: boolean; archived: boolean; created_time: string; last_edited_time: string; }; export type ListItemBlock = { id: string; object: string; type: "list_item"; childblocks: BlockWithChildren[]; has_children: boolean; archived: boolean; list_item: BulletedListItemBlock["bulleted_list_item"]; created_time: string; last_edited_time: string; }; export type BlockWithChildren = | (Block & { type: BlockType; childblocks: BlockWithChildren[]; }) | ListBlock | ListItemBlock;
The new ListBlock allows me to create a nested structure where I put adjacent bulleted_list_item or ordered_list_item types of the block into a ListBlock object and put the contents of these list item blocks into ListItemBlock objects. So the ListBlock represents my ul and ol tags while the ListItemBlock represents my li tag. I have used queues to convert all the bulleted_list_item or ordered_list_item types of blocks into a ListBlock object with an array of ListItemBlock objects as its children.
const createListBlock = ( blocktype: "bulleted_list" | "numbered_list", blocks: Array<BlockWithChildren>, ) => { const processedChildren: BlockWithChildren[] = blocks.map( (block: BlockWithChildren) => { if ( block.type == "bulleted_list_item" || block.type == "numbered_list_item" ) { const blockContent = block.type == "bulleted_list_item" ? block.bulleted_list_item : block.numbered_list_item; const ablock: ListItemBlock = { ...block, type: "list_item", list_item: blockContent, }; return ablock; } return block; }, ); const block: BlockWithChildren = { object: blocks[0].object, id: blocks[0].id, created_time: new Date(Date.now()).toISOString(), last_edited_time: new Date(Date.now()).toISOString(), has_children: true, archived: false, type: blocktype, childblocks: processedChildren, }; return block; }; export const extractListItems = ( blocks: Array<BlockWithChildren>, ): Array<BlockWithChildren> => { const postprocessed = Array<BlockWithChildren>(); const bulleted_list_stack = Array<BlockWithChildren>(); const numbered_list_stack = Array<BlockWithChildren>(); blocks.forEach((block: BlockWithChildren) => { switch (block.type) { case "bulleted_list_item": bulleted_list_stack.push(block); break; case "numbered_list_item": numbered_list_stack.push(block); break; default: if (bulleted_list_stack.length > 0) { postprocessed.push( createListBlock("bulleted_list", bulleted_list_stack), ); } else if (numbered_list_stack.length > 0) { postprocessed.push( createListBlock("numbered_list", numbered_list_stack), ); } postprocessed.push(block); bulleted_list_stack.length = 0; numbered_list_stack.length = 0; break; } }); if (bulleted_list_stack.length > 0) { postprocessed.push( createListBlock("bulleted_list", bulleted_list_stack), ); } else if (numbered_list_stack.length > 0) { postprocessed.push( createListBlock("numbered_list", numbered_list_stack), ); } return postprocessed; };
The extractListItems function takes the Array of BlockWithChildren, which doesn't have a nested list structure, and returns the Array of BlockWithChildren with the ListBlock objects. We need to call this function to pre-process any array of type BlockWithChildren before we create react components for it.
const renderBlock = (block: BlockWithChildren): React.ReactNode => { const childblocks: BlockWithChildren[] = block.has_children ? extractListItems(block.childblocks) // Preprocessing list items : []; const content: React.ReactNode = childblocks.map( (block: BlockWithChildren) => { return renderBlock(block); }, ); switch (block.type) { case "paragraph": return <Paragraph key={block.id} {...block} />; case "heading_1": return <Heading1 key={block.id} {...block} />; /* Truncated code for readability */ default: return <NotSupportedBlock key={block.id} reason={block.type} />; } }; export type PostContentProps = { blocks: Array<BlockWithChildren>; }; export const PostContent: React.FC<PostContentProps> = ({ blocks, }: PostContentProps) => { const blocksWithList = extractListItems(blocks); // Preprocessing list items return ( <article> {blocksWithList.map((block: BlockWithChildren) => { return renderBlock(block); })} </article> ); };
The react components for List blocks would be as follows.
type ListBlockProps = PropsWithChildren<ListBlock>; export const UnorderedList: React.FC<ListBlockProps> = ({ children, }: ListBlockProps) => { return <ul>{children}</ul>; }; export const OrderedList: React.FC<ListBlockProps> = ({ children, }: ListBlockProps) => { return <ol>{children}</ol>; }; type ListItemBlockProps = PropsWithChildren<ListItemBlock>; export const ListItem: React.FC<ListItemBlockProps> = ({ id, list_item, children, }: ListItemBlockProps) => { return ( <li> {renderText(id, list_item.rich_text)} {children} </li> ); };

Code Blocks

Code blocks have an extra layer of complexity over text blocks which is syntax highlighting. We will use highlight.js for syntax highlighting. First, we install highlight.js.
$ npm i highlight.js
In your _app.js, add your preferred highlight.js stylesheet. You can see a full list of highlight.js stylesheets here.
import "highlight.js/styles/github-dark-dimmed.css";
highlight.js contains support for a lot of languages, most of which you won’t be needing. Importing syntax highlighting for all the languages will cause your site to load slower. Even the common languages subset is very big. I would recommend creating another file where you configure your highlight.js instance.
import { HLJSApi } from "highlight.js"; import hljs from "highlight.js/lib/core"; import bash from "highlight.js/lib/languages/bash"; import c from "highlight.js/lib/languages/c"; import cplusplus from "highlight.js/lib/languages/cpp"; // add remove languages as per your preference export const getConfiguredHighlight = (): HLJSApi => { // register the languages hljs.registerLanguage("bash", bash); hljs.registerLanguage("shell", shell); hljs.registerLanguage("c", c); hljs.registerLanguage("cplus", cplusplus); // add aliases for flexibilty hljs.registerAliases(["c++", "cplusplus"], { languageName: "cplus" }); hljs.configure({ ignoreUnescapedHTML: true }); return hljs; };
To highlight the code syntax inside the react component for code blocks, we import the configured hljs and highlight the code element.
import { renderText } from "@components/notion/text"; import { getConfiguredHighlight } from "@util/highlight"; import { CodeBlock } from "@util/interface"; import { PropsWithRef, useEffect, useRef } from "react"; type CodeBlockProps = PropsWithRef<CodeBlock>; export const MultilineCodeBlock: React.FC<CodeBlockProps> = ({ id, code, }: CodeBlockProps) => { const ref = useRef<HTMLElement>(null); useEffect(() => { const hljs = getConfiguredHighlight(); if (ref.current) { hljs.highlightElement(ref.current); } }); return ( <pre className="bg-codeblock"> <code ref={ref} className={`${code.language}`}> {renderText(id, code.rich_text)} </code> </pre> ); };

Image Blocks

NextJS provides built-image optimization with its next/image component. You will have to specify the domains from where the images are fetched in your NextJS configuration. It is easy to add the domains whenever you upload an image to Notion. But it is not feasible to handle images which aren’t uploaded to Notion. So for now, till we find a workaround for that, we’ll avoid the external image case. You can check where your uploaded images are stored and add the domain name to your next.config.js.
module.exports = { images: { domains: [ "s3.us-west-2.amazonaws.com", ], }, });
A problem you would encounter with the next/image component is displaying responsive images without knowing the image size beforehand. We can solve that using the fill layout option and CSS styling.
type ImageProps = PropsWithRef<ImageBlock>; export const BlogImage: React.FC<ImageProps> = ({ id, image }: ImageProps) => { const altText = image.caption ? image.caption.map((richText) => richText.plain_text).join(" ") : "Some image"; const src = image.type == "file" ? image.file.url : "external"; const children = renderText(id, image.caption); if (src == "external") { return ( <NotSupportedBlock key={id} reason={`Image type ${image.type} not supported`} /> ); } return ( <figure className="blog__image"> <Image src={src} layout="fill" className="image" alt={altText} /> {children && <figcaption>{children}</figcaption>} </figure> ); };
.blog__image { width: 100%; position: relative; > div, span { position: unset !important; } .image { object-fit: contain; width: 100% !important; position: relative !important; height: unset !important; } }
Please note I have used SCSS; the CSS code snippet for this will be slightly different.

What’s next?

  • You can create react components for other blocks like embed helping you create a more rich user experience.
  • You can generate your non-blog pages, like an on-site resume or details about your projects etc., from Notion as well. (I have done that so you can refer to that in the source code).
  • You can use dynamic loading to improve the performance of your site.