React, Remix, and Notion API: The Perfect Trifecta for Your Code Blog

Brandon Schabel
Nookit Dev
Published in
12 min readDec 17, 2022

Why Notion and Remix?

Notion has a very powerful Rich Text Editing(RTE) system. I’m not trying to reinvent the wheel. Using notion allows me to Utilize the RTE so I don’t have to build all of that functionality, I just need to be able to display what Notion outputs. It also acts as a database so I’m able to create titles, post images, subtitles, tags, among other things attached to the post. Plus it’s simple and easy there is no database, what programmer doesn’t love that? Remix on the other hand utilizes the web platform so it makes things like caching, data loading, and forms very easy. Most importantly Remix is very fast.

Connecting To The Notion API and Creating A Blog Ready Notion Database

First, we need to go to https://www.notion.so/my-integrations and set up a new integration. Name your integration and for the purpose of this tutorial you really only need to read the content but if you would like to explore further you can enable the other permissions. Next, we need to create a Notion database that will hold the blog posts. In the Notion, sidebar click on “Add A Page”, once the page is created, create a list database. An example image is shown below.

Next, we need to add the properties Click on the 3 dots and then properties and add “Post Image” as the Files and Media, “Public” as the checkbox type, “Sub Title” as the text type, and “last edited time” type — you do not need to specify a name for this, this is a default property that already has a name assigned.

Your final properties should look like the following:

Next, you need to share your database with the integration that you set up. When you’re on your database page click the 3 dots in the very top right corner of your Notion app shown below

Then under the database itself click the 3 dots shown below(not the same 3 dots above), I know it’s confusing! Click add connection and select the name of the integration that you created.

You should now have access to the database from the API. To get the database ID click on share in the upper right-hand corner, and click on copy link, paste that link and it should look something like this:

https://www.notion.so/5a39not71903a4your030q09834database7f6?v=9361234d1baec49828b20daf092322

Pay close attention — you want to copy everything after **notion.so/** and before the ?v=

So in this case your database ID would be 5a39not71903a4your030q09834database7f6

rename .env.example to .env and add your Notion API key to NOTION_API_KEY and then using that ID you just got from your database set that to your NOTION_DATABASE variable.

So your .env should look something like the following

NOTION_API_KEY=secret_243nice9587432an35409try8adf
NOTION_DATABASE=5a39not71903a4your030q09834database7f6

Diving Into The Code

I would expect you to be somewhat familiar with React and some familiarity with Remix just so you don’t get completely lost. The full source code is at the bottom of the post. As with any blog you’re going to be brought to the home page when you visit the domain. In this case https://blackcathacks.com/ the below file is the home page for the blog.

clone the project:

https://github.com/brandon-schabel/remix-notion-blog/tree/blog-post-starter

Important: I’ve created a branch called blog-post-starter so if you’re following along please make sure to be on that branch

git checkout blog-post-starter

The starter branch contains the notion API client, prismjs for syntax highlighting, and we are using tailwind for styling. In this project I am using pnpm so if you’re using something else just adjust accordingly.

Install pnpm if you want to use it

npm install -g pnpm

If not you can stick to just npm as well you shouldn’t have any issues.

Install the packages by running

pnpm install

You can start the project by running

pnpm run dev

app/routes/index.tsx

import { Link, useLoaderData } from "@remix-run/react";
import { PageImage } from "~/components/page-image";
import { tenMinutes, week } from "~/constants/caching-times";
import { retrieveNotionDatabase } from "~/utils/notion.server";
import {
getPageSubTitle,
getPageTitle,
getPostCreatedAt
} from "~/utils/render-utils";

export function headers() {
return {
"Cache-Control": `public, max-age=${tenMinutes}, s-maxage=${tenMinutes} stale-while-revalidate=${week}`,
};
}

// in the loader retrieve all the posts rom the notion database
// notice here we have a .env variable for the notion database id
export async function loader() {
const pages = await retrieveNotionDatabase(process.env.NOTION_DATABASE || "");
return { pages };
}

export default function Index() {
// on the client side we can use the useLoaderData hook to get the data
// returned from the loader
const { pages } = useLoaderData<typeof loader>();

return (
<div className="flex flex-col justify-center items-center w-full">
{pages.results.map((page: any) => {
// here we iterate over the pages and render a link to each page
// along with the title, subtitle, and created date
return (
<div key={page.id}>
<Link to={`/posts/${page.id}`}>
<div className="shadow-md">
{/* we use a component to grab the pages image */}
<PageImage page={page} />
</div>

<div className="font-bold text-lg text-center">
{/* return Text component that grabs the pages title */}
{getPageTitle(page)}
</div>

<div className="text-center">
<div>{getPageSubTitle(page)}</div>
</div>

<div>{getPostCreatedAt(page).toDateString()}</div>
</Link>
</div>
);
})}
</div>
);
}

Since this is a blog we can utilize caching since all clients visiting the site will be seeing the same posts.

This Cache-Control header is specifying how the client and any intermediate caches can cache the response for this particular resource. The public directive indicates that the response can be cached by both the client and any intermediate caches. The max-age and s-maxagedirectives specify that the client and intermediate caches can cache the response for a maximum of 10 minutes before it must be revalidated. The stale-while-revalidatedirective specifies that if the response is stale (i.e., it has been cached for longer than 10 minutes), the cache can continue to serve it while it asynchronously revalidates it in the background. This allows the client to receive a response quickly, while still ensuring that the cached data is kept up to date.

Next, the loader loads all the posts for the blog. Later on, we will only fetch the 10 most recent posts per page, but for today we will not be implementing that. We utilize the notion API(https://github.com/makenotion/notion-sdk-js) to fetch all the posts, demonstrated in the function below.

app/utils/notion.server.ts

export const retrieveNotionDatabase = async (databaseId: string) => {
const response = await notion.databases.query({
database_id: databaseId,
// sort by the most recently created posts
sorts: [{ property: "Created", direction: "ascending" }],
// filter out any posts that are not published
filter: { property: "Public", checkbox: { equals: true } },
});

return response;
};

Next the loader loads all the posts for the blog. Later on we will only fetch the 10 most recent posts per page, but for today we will not be implementing that. We utilize the notion API(https://github.com/makenotion/notion-sdk-js) to fetch all the posts, demonstrated in the function below.

app/routes/post.$id.tsx

import type { LoaderArgs, MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Fragment } from "react";
import { PageImage } from "~/components/page-image";
import { Text } from "~/components/text";
import { tenMinutes, week } from "~/constants/caching-times";
import prismCss from "~/styles/prism.css";
import { retrieveNotionBlock, retrieveNotionPage } from "~/utils/notion.server";
import { renderBlock } from "~/utils/render-block";

export function links() {
return [{ rel: "stylesheet", href: prismCss }];
}

export function headers() {
return {
"Cache-Control": `public, max-age=${tenMinutes}, s-maxage=600 stale-while-revalidate=${week}`,
};
}

export const meta: MetaFunction = ({ data }) => {
return {
charset: "utf-8",
title: data?.page?.properties?.Name?.title[0]?.plain_text || "Blog Article",
viewport: "width=device-width,initial-scale=1",
};
};

export async function loader({ params }: LoaderArgs) {
const page = await retrieveNotionPage(params.id || "");

let blocks: any[] = [];

try {
const blocksResult = await retrieveNotionBlock(page.id);

blocks = blocksResult.results;
} catch (e) {
console.error(e);
}

return { page, blocks };
}

export default function () {
const { page, blocks } = useLoaderData<typeof loader>();

return (
<div className="w-full flex justify-center">
<div className="max-w-full w-[900px]">
<div className="px-6 md:px-24">
<div className="flex flex-col justify-center items-center font-bold text-4xl">
<h1>
{/* render the page title */}
<Text text={page?.properties?.Name.title} />
</h1>
{/* page image component */}
<PageImage page={page} />
</div>
<section>
{/* iterate through all blocks and render out all the data */}
{blocks.map((block) => (
<Fragment key={block.id}>{renderBlock(block)}</Fragment>
))}
</section>
</div>
</div>
</div>
);
}

You’ll notice the links function, this tells Remix to load the Prism CSS, which allows us to code syntax highlighting. You’ll recognize headers from the posts page. The “meta” function will set meta tags for your HTML. This is useful for SEO and it’s important in this case to update the title and description so on search engines will inform the users of the content, and it will also update the title in the browser tab. The Loader loads the data for the page, as well as all the blocks for the page. Just like the home page, we render out the title and the image, except here we are only rendering one post. The most important thing going on here is the blocks.map, on Notion you write your pages in blocks. This is the power of the rich text editing system in Notion.

app/utils/render-block.tsx

import { ClientBlock } from "~/components/client-block";
import { CodeBlock } from "~/components/code-block";
import { Text } from "~/components/text";

const BlockWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<div className="leading-normal mt-[2px] mb-[1px] whitespace-pre-wrap py-[3px] px-[2px] break-words min-h-[1em]"
>
{children}
</div>
);
};

export const renderBlock = (block: any) => {
const { type, id } = block;
const value = block[type];

switch (type) {
case "paragraph":
return (
<BlockWrapper>
<p>
<Text text={value.rich_text} />
</p>
</BlockWrapper>
);
case "heading_1":
return (
<BlockWrapper>
<h1>
<Text text={value.rich_text} />
</h1>
</BlockWrapper>
);
case "heading_2":
return (
<BlockWrapper>
<h2>
<Text text={value.rich_text} />
</h2>
</BlockWrapper>
);
case "heading_3":
return (
<BlockWrapper>
<h3>
<Text text={value.rich_text} />
</h3>
</BlockWrapper>
);
case "bulleted_list_item":
case "numbered_list_item":
return (
<BlockWrapper>
<li>
<Text text={value.rich_text} />
</li>
</BlockWrapper>
);
case "to_do":
return (
<div>
<label htmlFor={id}>
<input type="checkbox" id={id} defaultChecked={value.checked} />{" "}
<Text text={value.rich_text} />
</label>
</div>
);
case "toggle":
return (
<details>
<summary>
<Text text={value.rich_text} />
</summary>

{/* For some reason the toggle doesn't load the content of the toggle,
so the ClientBlock does client side loading of the toggle block */}
<ClientBlock id={id} />
</details>
);
case "child_page":
return <p>{value.title}</p>;
case "image":
const src =
value.type === "external" ? value.external.url : value.file.url;
const caption = value.caption ? value.caption[0]?.plain_text : "";
return (
<figure>
<img src={src} alt={caption} />
{caption && <figcaption>{caption}</figcaption>}
</figure>
);
case "code":
// component that handles the rendering of code blocks
return <CodeBlock text={value?.rich_text[0]?.plain_text} />;
default:
return `❌ Unsupported block (${
type === "unsupported" ? "unsupported by Notion API" : type
})`;
}
};

/app/utils/render-utils.tsx

import { Text } from "~/components/text";

export const getPageMainImageUrl = (page: any) => {
return page?.properties["Post Image"]?.files[0]?.file?.url;
};

export const getPageTitle = (page: any) => {
return <Text text={page.properties.Name.title} />;
};

export const getPageSubTitle = (page: any) => {
const subtitleProperties = page.properties["Sub Title"].rich_text;
return <Text text={subtitleProperties} />;
};

export const getPostCreatedAt = (page: any) => {
const createdAtProperties = page.properties["Created"].created_time;

return new Date(createdAtProperties);
};

Components Folder

app/components/client-block.tsx

import { useFetcher } from "@remix-run/react";
import { Fragment, useEffect } from "react";
import { renderBlock } from "~/utils/render-block";

export const ClientBlock = ({ id }: {id: string}) => {
const fetcher = useFetcher();

// will load block data and children and render it
useEffect(() => {
fetcher.load("/api/get-block/" + id);
}, [id]);

return (
<div>
{fetcher?.data?.blockData?.results.map((block) => (
<Fragment key={block.id}>{renderBlock(block)}</Fragment>
))}
</div>
);
};

We needed code syntax highlighting for this blog this is obviously very important for the code blog. I have already downloaded and configured the prism file at app/utils/prism.ts, If you need syntax highlight for another language you can download the code bundle from their website: https://prismjs.com/download.html and replace the contents of app/utils/prism.ts. I did have a slight issue where it would crash when loading the script on the server. So on line ~142 you’ll see that I added this if (typeof document === "undefined") return; This will prevent the server from trying to load/access the document, since Prism should only load on the client.

app/components/code-block.tsx

import { useEffect } from "react";
import Prism from "~/utils/prism";

export const CodeBlock = ({ text }: { text: string }) => {
useEffect(() => {
// prisma highlight all activates the syntax highlighting for all code blocks
if (typeof window !== "undefined") {
Prism.highlightAll();
}
}, []);

return (
<div className="relative flex justify-center">
<button
className="btn bg-slate-700 absolute text-white right-2 p-2 rounded-md"
onClick={() => {
navigator.clipboard.writeText(text);
}}
>
Copy
</button>
<div className="max-w-[90vw] lg:max-w-full w-full">
<pre className="language-tsx ">
<code className="language-tsx ">{text}</code>
</pre>
</div>
</div>
);
};

We can add a main image for the post, so I’ve built a simple component that will get the main image for the page and display it. This is used both on the home page and within the post page.

app/components/page-image.tsx

import classNames from "classnames";
import { getPageMainImageUrl } from "~/utils/render-utils";

export const PageImage = ({
page,
size = "md",
}: {
page: any;
size?: "sm" | "md" | "lg";
}) => {
const pageImageUrl = getPageMainImageUrl(page);

if (!pageImageUrl) {
return null;
}

return (
<img
src={pageImageUrl}
width="auto"
className={classNames("rounded-lg shadow-md w-fit", {
"h-32": size === "sxm",
"h-64": size === "md",
"h-96": size === "lg",
})}
/>
);
};

This text component is used to render the Notion Text block. app/components/text.tsx

import classNames from "classnames";

export const Text = ({
text,
className,
}: {
text: any;
className?: string;
}) => {
if (!text) {
return null;
}

return text.map((value: any) => {
const {
annotations: { bold, code, color, italic, strikethrough, underline },
text,
} = value;

let backgroundColorName = null;
let isBackgroundColor = false;


// if color end with _background, set background color
if (color?.endsWith("_background")) {
// parse color name
backgroundColorName = color.split("_")[0];
isBackgroundColor = true;
}
return (
<span
className={classNames(className, {
"font-bold": bold,
"font-italic": italic,
"font-mono bg-neutral-400 py-1 px-2 rounded-sm text-red-500": code,
"line-through": strikethrough,
underline: underline,
})}
style={
color !== "default" && !isBackgroundColor
? { color }
: {
backgroundColor: backgroundColorName,
}
}
key={text?.link ? text.link : text?.content || "No Content"}
>
{text?.link ? (
<a href={text?.link?.url}>{text.content}</a>
) : (
text?.content || "No Content"
)}
</span>
);
});
};

We need to occasionally client side load some blocks. For example for a toggle list for some reason I couldn’t get it to load all the content on the server so we have to load it later. I can’t see a case where I’d use it but I added support for it just in case.

app/routes/api/get-block.$id.tsx

import type { LoaderArgs } from "@remix-run/node";
import { retrieveNotionBlock } from "~/utils/notion.server";

export async function loader({ request, params }: LoaderArgs) {
const blockData = await retrieveNotionBlock(params.id || "");

return { blockData };
}

Now you can write your first blog post!

Create a new page within the database, fill it with some content, and make sure to click the “Public” checkbox which will now make your post visible. Visit http://localhost:3000 and you should now see your post, and you can click on it.

Ideas to extend this project

  • Update the favicons to your brand image
  • Add your brand image to somewhere on the site
  • Create a navbar
  • Create a recent posts section
  • Create an about page
  • Search function
  • Post tagging and filtering by tags
  • Explore Notion’s API for other ideas
  • Check out other blogs and find features to implement!

Source code available here:

https://github.com/brandon-schabel/remix-notion-blog

The completed code is on the blog-post-completed branch

The main branch will contain new features as I develop the blog. I probably won’t add anything else to this blog post, but I will more than likely add it to the Readme.

I created a blog using the code in this post here:

https://www.codersoutpost.com/

I’ll be continually updating the code to the blog as I see fit. I hope you enjoyed this point and if you found it helpful then please give me a follow. :)

--

--

Brandon Schabel
Nookit Dev

Previously SWE at Stats Perform. Open Source contributor who writes about my work - exploring new tech like Bun and developing Bun Nook Kit.