Sergio Xalambrí

Using Collected Notes as CMS

Now that Collected Notes has an API to get the notes you wrote here it's possible to use it as a CMS (Content Management System) for a custom website or blog. And it's actually pretty simple! Let's build one using Next.js for our Stack.

Create Next Project

First, let's create the Next.js app.

# Init project
$ yarn init --yes
# Install dependencies
$ yarn add next react react-dom marked swr
# Install development dependencies
$ yarn add -D @types/node @types/react typescript
# Create gitignore
$ npx gitignore node

And let's add these scripts to the package.json scripts key.

{
  "dev": "next",
  "build": "next build",
  "start": "next start"
}

Create Data Fetching Helpers

To make our code a little bit easy to follow, let's create a two files src/data/notes.ts and src/data/site.ts with the functions to fetch the Collected Notes API and the interface of the returned data.

// src/data/notes.ts
export interface Note {
  id: number;
  site_id: number;
  user_id: number;
  body: string;
  path: string;
  headline: string;
  title: string;
  created_at: string;
  updated_at: string;
  visibility: string;
  url: string;
}

export function readNote(site: string, path: string): Promise<Note> {
  return fetch(`https://collectednotes.com/${site}/${path}.json`).then((res) =>
    res.json()
  );
}
// src/data/site.ts
import useSWR, { ConfigInterface } from "swr";
import { Note } from "./notes";

export interface Site {
  site: {
    id: number;
    user_id: number;
    name: string;
    headline: string;
    about: string;
    host: any;
    created_at: string;
    updated_at: string;
    site_path: string;
    published: boolean;
    tinyletter: string;
    domain: string;
  };
  notes: Note[];
}

export function readSite(site: string): Promise<Site> {
  return fetch(`https://collectednotes.com/${site}.json`).then((res) =>
    res.json()
  );
}

export function useSite(site: string, options: ConfigInterface) {
  return useSWR<Site, never>(site, readSite, options);
}

Our readSite and readNote functions receive the site from where we want to read our notes and the path of the note.

There is also a useSite custom React hook to let us fetch the site data client-side, we will come back to this later.

Create First Page

With our data fetching modules ready, we can work on the code of the pages, let's create the first page on src/pages/index.tsx, this will be the content:

import { GetStaticProps, InferGetStaticPropsType } from "next";
import Link from "next/link";
import { Note } from "data/notes";
import { readSite, Site, useSite } from "data/site";

// This function will fetch the list of notes and the site data at build time
export const getStaticProps: GetStaticProps<Site> = async () => {
  const { site, notes } = await readSite(process.env.SITE_PATH);

  return { props: { site, notes } };
};

// This is the list of notes component, it will receive the list of notes, and
// site data, getStaticProps fetch at build time as props
export default function Notes(
  props: InferGetStaticPropsType<typeof getStaticProps>
) {
  // we use the props as initial value for useSite so in case we add a new note
  // this will ensure we revalidate against the API and get the updated list
  // without deploying again 🤯
  const { data } = useSite(process.env.SITE_PATH, {
    initialData: props,
    revalidateOnMount: true,
  });

  return (
    <section>
      <header>
        <h1>{data.site.name}</h1>
      </header>
      <ul>
        {data.notes.map((note) => (
          <li key={note.id}>
            <Link href="/notes/[slug]" as={`/notes/${note.path}`}>
              <a>
                <article>
                  <h2>{note.title}</h2>
                  <p>{note.headline}</p>
                </article>
              </a>
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

Create Note Page

And now, we can create the page to render a single note, this will be created as src/pages/notes/[slug].tsx

import {
  GetStaticProps,
  GetStaticPropsContext,
  InferGetStaticPropsType,
  GetStaticPaths,
} from "next";
import { useRouter } from "next/router";
import marked from "marked";
import { readNote, Note } from "data/notes";
import { readSite, Site } from "data/site";
import Link from "next/link";

function unwrap<Value>(value: Value | Value[]): Value {
  if (Array.isArray(value)) return value[0];
  return value;
}

// Here we fetch the site data to get the full list of notes, this let us know
// what notes we have at build time, we also enable the fallback, this makes
// Next.js render our component below in fallback mode while fetching the
// data of the note, in case it doesn't exist yet
export const getStaticPaths: GetStaticPaths = async () => {
  const { notes } = await readSite(process.env.SITE_PATH);
  const paths = notes.map((note) => {
    return { params: { slug: note.path } };
  });
  return { paths, fallback: true };
};

// Here we fetch the individual note data, and the site data, at build time
export const getStaticProps: GetStaticProps<{
  note: Note;
  site: Site["site"];
}> = async ({ params }: GetStaticPropsContext) => {
  const { slug } = params;
  const { site } = await readSite(process.env.SITE_PATH);
  const note = await readNote(process.env.SITE_PATH, unwrap<string>(slug));
  return { props: { note, site } };
};

// This is our note component, it receive the site data and the note as props
export default function Note(
  props: InferGetStaticPropsType<typeof getStaticProps>
) {
  // here we detect if the page should render as a fallback, below we will use
  // this `isFallback` to render a `Loading {something}...` message or the real
  // data
  const { isFallback } = useRouter();
  return (
    <section>
      <header>
        <h2>
          <Link href="/">
            <a>{isFallback ? "Loading site title..." : props.site.name}</a>
          </Link>
        </h2>
      </header>
      <article
        dangerouslySetInnerHTML={{
          __html: isFallback
            ? "<h1>Loading note title...</h1><p>Loading note body...</p>"
            : marked(props.note.body),
        }}
      />
      <footer>
        <p>
          {isFallback ? (
            "Loading note metadata..."
          ) : (
            <>
              Updated on{" "}
              <time dateTime={props.note.updated_at}>
                {new Intl.DateTimeFormat("en-US", {
                  weekday: "long",
                  year: "numeric",
                  month: "long",
                  day: "numeric",
                }).format(new Date(props.note.updated_at))}
              </time>
            </>
          )}
        </p>
      </footer>
    </section>
  );
}

Deploying

Lastly, we need to deploy it, we can do it easily with Vercel, create an account if you don't have one, and push your blog code to GitHub, then import it from the Vercel Dashboard, you can also deploy the repo I already have prepared with one-click

You will need to add a SITE_PATH environment variable to your website to customize the website path you are using, if your Collected Notes website is https://collectednotes.com/esacrosa then esacrosa is your SITE_PATH.

Styles

While the example code doesn't have any style, the website deployed with the button above have styles, I used Tailwind CSS for that, you can check the code in https://github.com/sergiodxa/collected-notes-next-blog.


6 months ago

Sergio Xalambrí