• Accueil
  • À propos
  • Formations
  • Blog
  • Contact
AccueilBlogL‘outil Notion - Partie 2 : Utiliser le SDK avec NextJS
Benjamin Naxos
13 février 2024
L‘outil Notion - Partie 2 : Utiliser le SDK avec NextJS
cover
content
content
contentcontent
yarn add @notionhq/client
const nextConfig = {
    env: {
        NOTION_TOKEN: 'secret_xxxxxxxxxxxxxxxxxxxxxx',
        HOMEPAGE_ID: 'XXXXXXXXXXXXXXXXXXXXXXXX',
				DATABASE_ID: 'XXXXXXXXXXXXXXXXXXXXXXXX'
    }
};
content
export const fetchWrapper = async (
    url: RequestInfo, init?: RequestInit
): Promise<Response> => {
    const next = { next: { tags: ['blog'], revalidate: 10 } }
    const params = {
        ...init,
        ...next
    }
    const responseOriginal = await fetch(url, params);
    return responseOriginal.clone();
}
import { Client, iteratePaginatedAPI } from "@notionhq/client"
import { PageObjectResponse, BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints"
import { NOTION_TOKEN } from "../../constants"
import { NotionPage } from '../classes/NotionPage'
import 'server-only'
import { fetchWrapper } from "./fetchWrapper"

// Instanciation du client Notion
const notion = new Client({ auth: NOTION_TOKEN, fetch: fetchWrapper })

export const getPageDetails = async (pageid): Promise<NotionPage | undefined> => {
  try {

    // Appel de l'api pour récupérer les propriétés de la page
    const pageProperties = await notion.pages.retrieve({ page_id: pageid }) as PageObjectResponse

    // Appel de l'api pour récupérer les blocks de la page
    const blocks: BlockObjectResponse[] = []
    for await (const block of iteratePaginatedAPI(notion.blocks.children.list, {
      block_id: pageid,
    })) {
      blocks.push(block as BlockObjectResponse)
    }
    
    return new NotionPage(pageProperties, blocks)

  } catch (error) {
    console.log(error)
    return undefined
  }
}
import { Client } from "@notionhq/client"
import { QueryDatabaseResponse } from "@notionhq/client/build/src/api-endpoints"
import { NOTION_TOKEN } from "../../constants"
import 'server-only'
import { fetchWrapper } from "./fetchWrapper"

// Instanciation du client Notion
const notion = new Client({ auth: NOTION_TOKEN, fetch: fetchWrapper })

export const getArticles = async (databaseid): Promise<string[] | undefined> => {
  try {

    // Appel de l'api pour requêter la base de données
    // On met les articles par odre de publication du plus récent au plus ancien
    // On ne prend que les articles au statut terminé
    const response = await notion.databases.query({
      database_id: databaseid,
      filter: {
        property: 'State',
        status: {
          "equals": "Terminé"
        }
      },
      sorts: [
        {
          property: 'Published',
          direction: 'descending',
        },
      ],
    }) as QueryDatabaseResponse;

    const pages: string[] = []
    for (const page of response.results) {
      pages.push(page.id);
    }

    return pages;

  } catch (error) {
    console.log(error)
    return undefined
  }
}
import { PageObjectResponse, TextRichTextItemResponse } from "@notionhq/client/build/src/api-endpoints"
import { ITitle } from "../interfaces/ITitle"

export class NotionTitle {
    _title: string

    public get title() {
        return this._title
    }

    constructor(response: PageObjectResponse) {

        let titleProperty: ITitle

        if (response.parent.type === "database_id") {
            // Dans la base de donnée, Le Titre est le nom de l'article
            titleProperty = response.properties["Name"] as ITitle
        } else {
            // Titre
            titleProperty = response.properties["title"] as ITitle
        }

        this._title = (titleProperty.title as Array<TextRichTextItemResponse>).map(t => t.plain_text).join("")
    }
}
import { TextRichTextItemResponse } from "@notionhq/client/build/src/api-endpoints"

export interface ITitle {
    id: string
    type: string
    title: Array<TextRichTextItemResponse>
}
import { PageObjectResponse, BlockObjectResponse, ParagraphBlockObjectResponse, RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints"

export class NotionDescription {
    _description: string

    public get description() {
        return this._description
    }

    constructor(response: PageObjectResponse, blocks: BlockObjectResponse[]) {
        this._description = ""
        for (const b of blocks) {
            switch (b.type) {
                // on considère que le premier paragraphe rempli est notre description
                case "paragraph":
                    const paragrah = b as ParagraphBlockObjectResponse
                    const paragraphCollection = paragrah.paragraph.rich_text as RichTextItemResponse[];
                    this._description = paragraphCollection.map(t => t.plain_text).join("")
                    break
            }
            if (this._description.length > 0)
                break
        }
    }
}
import { PageObjectResponse, BlockObjectResponse } from "@notionhq/client/build/src/api-endpoints"
import { NotionTag } from "./NotionTag"
import { NotionIcon } from "./NotionIcon"
import { NotionTitle } from "./NotionTitle"
import { NotionDescription } from "./NotionDescription"
import { NotionCover } from "./NotionCover"
import { NotionDate } from "./NotionDate"
import { NotionFiles } from "./NotionFiles"
import { NotionCreatedBy } from "./NotionCreatedBy"
import { NotionBlock } from "./NotionBlock"

export class NotionPage {
    _id: string
    _description: NotionDescription | undefined
    _cover: NotionCover | undefined
    _title: NotionTitle | undefined
    _category: NotionTag | undefined
    _icon: NotionIcon | undefined
    _date: NotionDate | undefined
    _imageAuteur: NotionFiles | undefined
    _nameAuteur: NotionCreatedBy | undefined
    _blocks: NotionBlock[] | undefined

    public get id() {
        return this._id
    }

    public get title() {
        return this._title
    }

    public get description() {
        return this._description
    }

    public get cover() {
        return this._cover
    }

    public get category() {
        return this._category
    }

    public get icon() {
        return this._icon
    }

    public get date() {
        return this._date
    }

    public get imageauteur() {
        return this._imageAuteur
    }

    public get nameauteur() {
        return this._nameAuteur
    }

    public get blocks() {
        return this._blocks
    }

    constructor(response: PageObjectResponse, blocks: BlockObjectResponse[]) {
        this._id = response.id
        this._icon = new NotionIcon(response)
        this._title = new NotionTitle(response)
        this._category = new NotionTag(response, "Category")
        this._description = new NotionDescription(response, blocks)
        this._cover = new NotionCover(response)
        this._date = new NotionDate(response, "Published")
        this._imageAuteur = new NotionFiles(response, "ImageAuteur")
        this._nameAuteur = new NotionCreatedBy(response, "Créée par")
        this._blocks = []
        blocks.forEach(b => {
            let block = new NotionBlock(b)
            this._blocks?.push(block)
        });
    }
}
import Image from 'next/image'
import { Icon } from "./Icon"
import { Tag } from "./Tag"
import { getPageDetails } from '../lib/getPageDetails'
import Link from 'next/link'

export async function Article({ pageid }) {

  var page = await getPageDetails(pageid)

  return (
    page
      ?
      <ArticleTemplate key={page.id} id={page.id} title={page.title?.title} emoji={page.icon?.emoji}
        icon={page.icon?.icon} cover={page.cover?.cover} categoryname={page.category?.name}
        categorycolor={page.category?.color} date={page.date?.date} imageauteur={page.imageauteur?.url}
        nameauteur={page.nameauteur?.name} description={page.description?.description} />
      : <ArticleSkeleton />
  )
}

// Rendu du header si on obtient la donnée
async function ArticleTemplate({ id, title, emoji, icon, cover, categoryname, categorycolor, date, imageauteur, nameauteur, description }) {
  return (
    <Link href={`/blog/` + id}>
      <article className="bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700 w-64">
        <Image priority={true} className="rounded-t-lg w-full" width={500} height={143.5} src={cover} alt="cover" />
        <div className="p-4">
          <h3 className="mb-2 font-bold tracking-tight text-gray-900 dark:text-white text-sm h-8 overflow-hidden">
            <Icon emoji={emoji} icon={icon} />
            &nbsp;{title}
          </h3>
          <p className="h-16 overflow-hidden text-xs font-light mb-2 text-gray-900">{description}</p>
          <Tag color={categorycolor} name={categoryname} />
          <div className="relative flex py-2 items-center">
            <div className="flex-grow border-t border-gray-400"></div>
          </div>
          <div className="flex flex-row items-start">
            <Image className="w-12 h-12 rounded-full shadow-lg" src={imageauteur} alt="auteur" width={100} height={48} />
            <div className="flex flex-col p-2">
              <span className="text-xs font-bold tracking-tight text-gray-900 dark:text-white">{nameauteur}</span>
              <span className="text-xs font-light text-gray-900">{date}</span>
            </div>
          </div>
        </div>
      </article>
    </Link>
  )
}

// Rendu du header pendant le chargement ou en cas d'erreur
export async function ArticleSkeleton() {
  return (
    <article role="status" className="bg-white border ...">
      ...
    </article>
  )
}
import '../vitrine/scss/themes.scss'
import Navbar from "../vitrine/components/Navbar";
import Footer from '../vitrine/components/Footer';
import { getArticles } from '../notion/lib/getArticles';
import { Suspense } from 'react';
import { Header, HeaderSkeleton } from '../notion/components/Header';
import { Article, ArticleSkeleton } from '../notion/components/Article';
import { DATABASE_ID } from '../constants';

export default async function Home() {
    
    var pages = await getArticles(DATABASE_ID)

    return (
        <>
            <Navbar />
            <main className="container mx-auto sm:px-4 mt-40 mb-20 max-lg:mt-20 max-lg:mb-10">
                <div className="justify-center flex flex-wrap">
                    <div className="pr-4 pl-4">
                        <Suspense fallback={<HeaderSkeleton />}>
                            <Header />
                        </Suspense>
                        <div className="flex flex-wrap justify-center  gap-4">
                            {pages?.map(function (page) {
                                return (
                                    <Suspense key={page} fallback={<ArticleSkeleton />}>
                                        <Article pageid={page} />
                                    </Suspense>
                                )
                            })}
                        </div>
                    </div>
                </div>
            </main>
            <Footer />
        </>
    );
}
import { getPageDetails } from "@/app/notion/lib/getPageDetails"
import { Block } from "@/app/notion/components/Block"
import '@/app/vitrine/scss/themes.scss'
import Navbar from "@/app/vitrine/components/Navbar"
import Link from "next/link"
import Image from 'next/image'
import { FaChevronRight } from "react-icons/fa"
import React from "react"
import CodeBlock from "@/app/notion/components/CodeBlock"

export default async function Page({ params }: { params: { slug: string } }) {

  var page = await getPageDetails(params.slug)

  return (<>
    <Navbar />
    <div className="container mx-auto mt-40 mb-20 max-lg:mt-20 max-lg:mb-10">
      <div className="justify-center flex flex-wrap">
        <div className="w-4/5">
          <Link href="/#home" className="mb-8">Accueil</Link>
          <FaChevronRight className="inline mb-1 ml-1 mr-1" size={12} />
          <Link href="/blog" className="mb-8">Blog</Link>
          <FaChevronRight className="inline mb-1 ml-1 mr-1" size={12} />
          <span className="text-sm">{page?.title?.title}</span>
          <div className="text-lg text-primary mt-8">{page?.nameauteur?.name}</div>
          <div className="text-lg mb-8">{page?.date?.date}</div>
          <div className="text-5xl mb-4">{page?.title?.title}</div>
          {page?.cover?.cover &&
            <Image priority={true} className="mb-4 mt-4 w-full bg-cover bg-center " width={500} height={143.5} src={page?.cover?.cover} alt="cover" />
          }
          <div className="hero-5-content">
            {page?.blocks?.map(function (block) {
              if (block.type === "code") {
                return <CodeBlock key={block.id} code={block.text} language={block.language} />
              }
              return (
                <Block key={block.id} block={block} />
              )
            })}
          </div></div></div></div></>
  )
}
Dans cet article nous allons utiliser le client Javascript officiel maintenu par l’équipe Notion qui est disponible
ici
De nombreux guides permettent de démarrer facilement une nouvelle application React JS ou Next JS, cet article va se concentrer uniquement sur le code lié aux appels à l’api Notion.
L’objectif
Le blog de
latelierit
se présente de la façon suivante côté Notion, nous avons une page d’accueil du blog qui contient la liste des articles :
Et une page détail qui affiche le contenu d’un article :
La cible c’est d’exposer ce blog sur le site
latelierit.fr
. L’outil Notion servira de “CMS” et les données seront récupérées pour être affichées de la façon suivante :
Installation
Commençons par installer le SDK Notion
Et ajoutons les variables d’environnement nécessaires pour appeler l’api :
NOTION_TOKEN : Le code secret obtenu au moment de la création de l’intégration comme expliqué dans
l’article
HOMEPAGE_ID : L’identifiant de la page principale
Sur la capture ci-dessus on voit que la page “A vos lunettes !!” est placée directement à la racine du Workspace, c’est la page principale, le point d’entrée du blog
C’est d’ailleurs sur cette page que nous avons activé la connexion avec l’intégration créée comme expliqué dans cet
article
Il faut cliquer sur le menu en haut à droite de la page (…) afin de “copier le lien” de la page :
https://www.notion.so/A-vos-lunettes-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?pvs=4
L’ID figurant dans l’url xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx est notre HOMEPAGE_ID
DATABASE_ID : L’identifiant de la base de donnée des articles
Comme pour le HOMEPAGE_ID, il faut cliquer sur “copier le lien” au niveau du noeud “Article” sous la page “A vos lunettes !!” et extraire l’ID de l’url
Structure du projet
Notre projet est découpé de la façon suivante :
Dossier “lib”
Le dossier lib contient la logique pour appeler l’api Notion via le SDK.
Par défaut le SDK Notion n’utilise pas “
fetch
” pour faire les appels HTTP, pourtant côté NextJS il est intéressant de l’utiliser car cela donne accès à des fonctionnalités de mise en cache automatique.
Nous créons donc une méthode
fetchWrapper
qui va nous permettre de préciser au SDK Notion que l’on souhaite utiliser
fetch
:
Nous créons ensuite une méthode
getPageDetails
qui permet de récupérer les propriétés et le détail d’une page :
On note qu’à l’instanciation du client on fournit la méthode
fetch
en paramètre du constructeur
Nous créons également une méthode
getArticles
qui permet de récupérer les pages contenues dans la base de donnée des articles :
Dossier “classes” et “interfaces”
Quand le SDK Notion est appelé, il renvoie des objets “response” (PageObjectResponse, BlockObjectResponse…). Pour faciliter l’affichage des données, nous créons des classes TypeScript intermédiaires qui vont parcourir les objets “response” et exposer la donnée dans des propriétés exploitables facilement.
Exemple avec le titre d’une page
Le titre d’une page faire partie de la liste des “properties” de l’objet “PageObjectResponse”
Par défaut, quand la page est l’enfant d’une base de donnée, le titre est stocké dans la propriété qui se nomme “Name” sinon le titre est stocké dans la propriété qui se nomme “title”
Une fois la propriété contenant le titre identifiée, nous exploitons le champ “plain_text”
On note l’utilisation d’une interface ITitle, le SDK Notion ayant omis d’exporter un type qui correspond au titre, nous créons donc une interface par souci de propreté (pour éviter les Any…) :
Exemple avec la description de l’article
Pour afficher une description de l’article sur la page qui liste les articles, on va chercher le premier bloc de type “Paragraph” dans la page, ce qui donne :
Toutes les classes suivent une logique similaire en s’appuyant sur la documentation Notion.
Pour construire une page complète, nous n’avons plus qu’à instancier toutes nos classes Notion de la façon suivante :
Dossier “components”
La logique des composants est propre à chaque application et s’occupe du rendu des données.
A titre d’exemple, le composant Article.tsx qui affiche la tuile d’une article sur la vue liste fonctionne de la façon suivante :
Le composant prend en paramètre l’ID de la page et appelle la méthode
getPageDetails
détaillée plus haut pour extraire les détails de l’article
Fonctionnement des pages
La page qui liste les articles fonctionne de la façon suivante :
Le composant appelle la méthode
getArticles
détaillée plus haut qui liste les articles, et il boucle sur la liste des pages. (Une page = un article dans la base de donnée)
La page détail d’un article fonctionne de la façon suivante :
Le composant appelle la méthode
getPageDetails
pour l’ID qui a été cliqué, et il boucle sur la liste des blocs contenu dans la page afin d’afficher tout l’article.
La méthode getPageDetails est appelé plusieurs fois dans les composants, mais NextJs s’occupe de la mise en cache comme détaillé
ici
Mot de la fin
L’api Notion et le SDK associé sont assez simple à utiliser mais la mise en place d’un blog complet est une tâche assez conséquente. Pourtant le jeu en vaut la chandelle car une fois la mécanique en place, vous profitez de la fluidité de l’outil Notion pour éditer vos articles sans avoir à vous connecter à un CMS dédié.