I believe that coding is a valuable skill that can open up many opportunities, and I want to help others learn and grow in this field. That's why I created this blog.
If you don’t care to read the entire blog although you will need to read the section on the Notion setup, you can get the source code at the bottom of the post. In this post I will go over how specifically I created this blog with Remix and Notion. At a high level we need to create a page where we fetch and display all the available posts and we also need to create a page for fetching and viewing an individual post. All code will be available in GitHub repo. I have added comments throughout the code to supplement the explanations in the blog post.
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.
First, we need to go to
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:
So in this case your database ID would be
rename
So your .env should look something like the following
.env
NOTION_API_KEY=secret_243nice9587432an35409try8adf
NOTION_DATABASE=5a39not71903a4your030q09834database7f6
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
clone the project:
Important:
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
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(
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;
};
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, this 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 it 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);
};
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/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.
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 };
}
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
Source code available here:
The completed code is on the
The