Creating a Code Blog with Remix and The Notion API

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.

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:

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

.env

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:

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-maxage
directives 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-revalidate
directive 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;
};

Displaying A Post

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);
};

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
  • Checkout other blogs and find features to implement!
  • Source code available here:

    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.