case study · 2023Minimal Norui - A Notion-based Portfolio Template

jorenrui's avatar

@jorenrui / June 24, 2023

4 min read

A minimal portfolio template. There's a profile picture on the left and a name and description on the right.

Minimal Norui is a simple portfolio template built with Next.js and powered by Notion. It uses Notion as the database wherein the user can update the info and it will quickly reflect on the site.

You can check out the documentation as to how to set this up.

Role
Individual Contributor
Tech Stack
  • TypeScript
  • Next.js
  • React.js
  • TailwindCSS
  • Notion API
  • Vercel
It shows that the portfolio site's info is updated when the user updates the Notion database.

Goals and Motivation

I wanted to try out the Notion API since I haven’t used it before. With that, I thought why not create sites with this? So I decided to create multiple templates and call it project Norui. Though this is just the first, I'm planning to continue this project and create more templates.

Tech Stack Decisions

Since I just want a quick prototype in creating this template and testing out the Notion API, I decided to use the tech that I'm most familiar with which are TailwindCSS, Next.js, and TypeScript. Moreover, you can easily compose APIs using Next.js API routes. So I decided to use it for fetching data from the Notion API.

Implementation

Since I want it to feel like the changes are real-time, I decided to use SWR for fetching data so that it refetches data on window focus. As for the Notion API, I decided to use a Notion SDK for JavaScript which is @notionhq/client. Using it like:

import { Client } from '@notionhq/client';

export const client = new Client({ auth: process.env.NOTION_API });

Then using Next.js API routes, I've built the fetcher for the notion data that I needed then formatted the data so that it can be used easily in the frontend:

...
import { client } from '@/lib/api/notion';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<IINfo | unknown>
) {
  try {
    const response = await client.databases.query({
      database_id: process.env.NOTION_DATABASE_ID || '',
    });

    const data = response.results.reduce((result, record: any) => {
      const field: string = record.properties.ID.rich_text[0]?.plain_text.trim() || '';
      const name: string = record.properties.Name.title[0]?.plain_text.trim() || '';
      const types: string[] = record.properties.Type.multi_select?.map((item: IOption) => item.name) || ['rich text'];

      if (field === 'links') {
        return ({
          ...result,
          [field]: {
            ...(result[field] || {}),
            [name]: record.properties.Value.rich_text,
          },
        });
      }

      return ({
        ...result,
        [field]: types.includes('file')
          ? record.properties.File.files[0]?.file.url
          : record.properties.Value.rich_text,
      });
    }, {} as IINfo);

    res.status(200).json(data);
  } catch (error) {
    res.status(500).json({ error });
  }
}

I've made the page of the site as static one, using getStaticProps where in I fetch the initial data and pass it as props to the page:

...
import { fetcher, getInfo } from '@/lib/api';

export const getStaticProps: GetStaticProps = async () => {
  try {
    const data = await getInfo();

    return {
      props: {
        data,
      },
    }
  } catch (error) {
    console.log(error);
  }

  return {
    props: {},
  }
}

Then in the page, I've used SWR and render the content:

...

const Home: NextPage<IProps> = ({ data: initialData }) => {
  const { data, error } = useSWR<IINfo>('/api/info', fetcher, { fallbackData: initialData });

  if (error) return <ErrorPage error={error} />;
  if (!data) return <Loader className="min-h-full" />;

  return (
    <Page className="flex items-center justify-center">
      <div className="p-4 flex flex-col justify-center gap-4 lg:flex-row">
        {data.profile_picture && (
          <div className="flex-1">
            <Image
              alt={`${data.name?.[0]?.plain_text}'s profile picture`}
              src={data.profile_picture}
              className="image grayscale"
              height={400}
              width={300}
              objectFit="cover"
            />
          </div>
        )}

        <div className="flex-1 max-w-lg my-auto">
          <h1 className="my-2 text-5xl font-bold font-serif text-gray-900">
            {data.name ? formatRichText(data.name) : 'Hello 👋'}
          </h1>
          <p className="my-1 text-sm text-gray-700">{formatRichText(data.headline)}</p>
          {data.description && (
            <p className="my-4 text-base text-gray-900">
              {formatRichText(data.description)}
            </p>
          )}

          {Object.keys(data.links || {}).length > 0 && (
            <ul className="mt-4 flex flex-wrap gap-x-4 gap-y-1">
              {Object.keys(data.links).map((link_description) => {
                const content = data.links[link_description][0];
                if (!content) return;

                return (
                  <li key={content.plain_text}>
                    <a href={link_description.toLowerCase() === 'email' ? `mailto:${content.plain_text}` : content.plain_text || '#'} target="_blank" rel="noopener noreferrer" className="inline-flex items-center justify-center underline underline-offset-2 decoration-2 text-gray-700 cursor-pointer">
                      {link_description}
                      <HiExternalLink className="ml-1 h-4 w-4" aria-hidden="true" />
                    </a>
                  </li>
                );
              })}
            </ul>
          )}

          {data.copyright && (
            <p className="mt-12 text-sm text-gray-700">
              {formatRichText(data.copyright)}
            </p>
          )}
        </div>
      </div>
    </Page>
  );
};