nextjs lolipop notion hosting ロリポップ へテムル

NotionをDBaaSにして「ロリポップ!」「ヘテムル」のサイトをもっと安全にしよう

nextjs lolipop notion hosting ロリポップ へテムル

筋トレの重量に伸びがなくなってきたため、ジムに行く回数を3回にして5ヶ月がたったlinyowsです。順調に重量は増え、食事制限はしてませんが代謝が増えたことで脂肪も大分燃焼されている気がします。あと、ジムが習慣になったことで、ジムに行かない日はちょっとソワソワしています。が、すこぶる元気です。今日は、「GitHub Actionsで「ロリポップ!」「ヘテムル」をもっと便利に使おう 」や「PRPLパターンで「ロリポップ!」「ヘテムル」のWordPressを爆速にしよう」の記事の続編?にあたる記事です(前回の公開が 2020年5月なので大分時間が空いてしまいました)。

また、先日のNotion Japanが主催するイベントで発表した内容の延長の話なので、興味がある方は下のスライドもご覧ください。

セキュリティ対応の必要性

ブログやサイトを自前で作る場合、WordPressを利用することは多いと思います。なぜなら、ブログやサイトのコンテンツ管理をWordPressに任せることで、表示に関するクリエイティブな部分に集中することができるからです。また、WordPressには、プラグインのエコシステムが充実しており、データ拡張のためのフィールド追加や、SEOに関する機能など、さまざまな便利機能をオプションとして利用することができます。これらのエコシステムを作るのは全世界の有志なので、当然ながら危険な脆弱性があることがしばしばあります。このような脆弱性は、利用者が継続的に管理しなければなりません。利用するプラグインが、メンテナンスされていればアップデートで問題ないでしょう。もしメンテナンスがされていなければ、プラグインの交換をしたり、利用を止めたり、などの対応をしなければなりません。そうしなければ、管理しているサイトが改竄されたり、乗っ取られる恐れがあります。なんだか物騒に聞こえますが、事実世の中で発生していることであります。

Headless CMSとしてのNotion

セキュリティインシデントにならないように、先述の通り継続的なセキュリティ対応が必要なのですが、管理するサイトの数に比例して運用が結構大変です。どうにか楽する方法はないのでしょうか。その1つの方法として考えられるのがマネージドなHeadless CMSを使うことです。世の中には、例えば、ContentfulHygraphといったマネージドサービスが存在しています。そんな中で今回提案するのはNotionを使う方法です。なぜ、Notionなのかというと、Notionを作業スペースとして使っている場合、その延長上にサイト管理ができるということです。また、Notionにはデータベースの機能とドキュメントの機能があり、それらの機能を公式にAPIとして提供しています。Notionはプロダクト的にHeadless CMSと謳ってはないものの、機能的にはHeadless CMSを満たしているというわけです。Notionは、柔軟性の高いデータベース設計が可能であることから、Database as a Serviceと言っても過言ではないでしょう。

NotionとNext.jsの組み合わせ

背景や意図を伝えたところで、いよいよNotionを使ってより安全で爆速のサイトを作っていきましょう。爆速にするために、今回、Next.jsのexportコマンドを使って静的サイトにします。ロリポップやへテムルでは、Node.jsランタイムを提供していないため、前回の記事同様に静的サイトにするというわけです。また、静的サイトはキャッシュ可能なため、ロリポップやへテムルの環境においては、より閲覧者に近いところでレスポンスを返すことができます。今回ハイレベルアーキテクチャは以下のようになります。GitHub Actionsでビルドしたりデプロイするのはこれまでと変わりませんが、コンテンツが丸っとNotion側にあるため、ロリポップはストレージ的役割となります。

Hi-Level Architecture

使用するもの

以下のサービスやツールを使用します。

  • ロリポップ!ハイスピードプラン
    • ロリポップ!が提供するドメイン
  • Next.js App
  • GitHub Repository
    • GitHub Actions(Next.jsのexportとSCPによるデプロイ)
  • Notion パーソナルプラン

ロリポップのセットアップ

まず、ロリポップのハイスピードプランを申し込みます(安心してください!クレカ登録無しでお試しで10日間使えます)。ロリポップのドメインは、通常 mute-hiji-3584 のようなhaikunateされたIDがサブドメインに入っています。今回はロリポップのドメインを使うので適切なIDを入力します。申込みが完了するとユーザー専用ページというコンソールサイトへ遷移します。

Lolipop Sign-up

アクセラレータの有効化

サイトレスポンスのパフォーマンスを上げるため、Web Proxyサーバのキャッシュを有効にするアクセラレーターをOnにします。左のナビゲーションの サーバーの設定・設定 > ロリポップ!アクセラレータ から設定できます。

Lolipop Cache Enabled

SSHの有効化

GitHub Actionsでビルド済みの静的サイトをSCPによってファイルをアップロードするため、SSHを有効にしておきます。左のナビゲーションの サーバーの管理・設定 > SSH から設定できます。

Lolipop SSH Enabled

Notion のセットアップ

Notionをパーソナルプランで申し込み、今回のサイトで使うページを作成します。私の例では、分かりやすいようにドメイン名のページを作りました。その中にサイトコンテンツが格納されるデータベースを作成します。今回はサンプルとして、ペパボのサービス紹介のサイトを作ります。必要なデータベースプロパティに 公開日時 URL Slug を日付型、URL型、テキスト型で追加します。データベースには、ペパボのサービスごとにページを追加していき、カバーにロゴ画像を設定します。

Notion Sign-up

Notion Database Properties

APIアクセストークンの作成

次に、Notion APIに使うアクセストークンを作成していきます。作成は、ナビゲーション左上の 設定コネクトインテグレーションを作成または管理する のリンクがあるので開きます。Notionではサービス拡張をインテグレーションと呼んでいて、API利用者もその一つという位置付けになるようです。今回の用途はブログコンテンツの読み込みを想定しているので権限は読み込みのみで設定指定おきます。作成すると、トークンが発行されます。さらに、このインテグレーションをNotionのページに割り当てる必要があります。先ほど作ったドメイン名のページに移動したら、ページ右上に ... のアイコンがあるのでその中のコネクトの追加から先ほど作ったインテグレーションを割り当てます。Notionの権限は、親ページに設定したものをその配下にある子や孫に対して引き継ぐため、これだけで配下のページは全てAPIからアクセス可能になります。

Notion Connect

Notion API Credential

Notion assign integration

データベースに情報を入力していくと以下のような状態になりました。ページ本文には、サイトのキャプチャや Notionのブロックの一つである コールアウト を設定したりしました。

Notion Example

Next.js Appの作成

さて、今度はサイトの方を作っていきます。Next.jsを使うのですが手軽に用意したいので、 create-next-app を使います。NotionのAPIによるデータ取得や表示を透過的に行うことができるパッケージ Notionate を追加します。そして、先ほど作成したAPIトークンとデータベースのIDを .env として保存します。データベースのIDは、データベースのページのURLが https://www.notion.so/102db5ac15844bc5a0b7f4419b2c33a0?v=c01d56f22a744b889a61edd186e2726a だったとしたら querystringを省いたbasenameを指定します。

$ npm i -g create-next-app
# 好みでTypeScriptを指定
$ create-next-app --ts linyows.her.jp
$ cd linyows.her.jp
$ yarn add notionate
$ cat << EOF > .env
NOTION_CACHE=true
NOTION_TOKEN=secret_*************************************
NOTION_DBID=102db5ac15844bc5a0b7f4419b2c33a0
EOF

共通処理の関数

Notion データベース APIには、filterやsortなどのクエリを渡すことができます。それらを定義し、データベースへのリクエストを共通化しておきます。 Notionate は、Notion APIからデータ取得と画像などのダウンロードを一気に行う関数 FetchDatabase を提供するので、 Next.jsの getStaticProps で呼び出す想定です。ローカルにダウンロードした画像のパスをAPIのレスポンスに付加したデータを返すので、必要な値をNext.jsのPropsにセットしてやります。今回は、データベースのプロパティである 公開日時が未来のものを除外しています。 Notionate はデータベースを全ページ取得しローカルファイルにキャッシュする仕組みになっているため、一件取得もその結果からfindするようにしています。また、Notion APIのレスポンスはかなり冗長なデータになるので、ページコンテンツとして使いやすいように build 関数で加工できるようにしておきます。また、Next.jsで exportするには 各ページのパスを渡してビルドできるようにしなければなりません。そのためのパスリストを返す関数も作っておきます。

import {
  FetchDatabase,
  QueryDatabaseParameters,
  RichTextItemResponse,
  SelectPropertyResponse,
  DateResponse,
  DBPageBase,
  QueryDatabaseResponseEx,
} from 'notionate'

export type DBPage = DBPageBase & {
  cover: {
    src: string
  }
  properties: {
    "タイトル": {
      type: "title"
      title: RichTextItemResponse[]
      id: string
    }
    Slug: {
      type: "rich_text"
      rich_text: RichTextItemResponse[]
      id: string
    }
    "公開日時": {
      type: "date"
      date: DateResponse | null
      id: string
    }
    "タグ": {
      type: "multi_select"
      multi_select: SelectPropertyResponse[]
      id: string
    }
  }
}

export type Service = {
  id: string
  title: string
  slug: string
  date: string
  tags: string[]
  cover: string
}

export const build = (page: DBPage): Service => {
  const p = page.properties
  return {
    id: page.id,
    title: p["タイトル"].title.map(v => v.plain_text).join(',') || '',
    slug: p.Slug.rich_text.map(v => v.plain_text).join(',') || '',
    date: p["公開日時"].date?.start || '',
    tags: p["タグ"].multi_select.map(v => v.name) || [],
    cover: page.cover.src,
  }
}

const q = {
  database_id: process.env.NOTION_DBID,
  filter: {
    property: '公開日時',
    date: {
      on_or_before: (new Date(Date.now())).toISOString(),
    },
  },
  sorts: [
    {
      property: '公開日時',
      direction: 'ascending',
    },
  ],
  limit: 10,
} as QueryDatabaseParameters

export const getServices = async () => {
  return await FetchDatabase(q)
}

export const getService = async (slug: string) => {
  const svc = await getServices()
  return svc.results.find(v => {
    const p = v as unknown as DBPage
    return p.properties.Slug.rich_text.map(vv => vv.plain_text).join(',') === slug
  })
}

export const getPaths = async () => {
  const svc = await getServices()
  return svc.results.map(v => {
    const p = v as DBPage
    const slug = p.properties.Slug.rich_text.map(v => v.plain_text).join(',')
    return { params: { slug } }
  })
}

各ページの変更

次に、各ページの変更をしていきます。 pages/_app.tsx は、各ページで使う Notionate のスタイルをロードしておきます。

import type { AppProps } from 'next/app'
import '../styles/globals.css'
import 'notionate/dist/styles/notionate.css'

function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

export default MyApp

pages/index.tsx は、Notionで言うギャラリービューを表示します。 Notionate がギャラリーコンポーネントを提供するので、共通化関数から取得したデータをコンポーネントに渡すだけです。 keysはギャラリーのカードで表示したいプロパティとその順番になります。 href は、カードのリンクパスとなり、データベースのプロパティの Slugを使ったパスとするために指定します。

import { GetStaticProps, NextPage } from 'next'
import type { ReactElement } from 'react'
import Link from 'next/link'
import { QueryDatabaseResponseEx } from 'notionate'
import { Gallery } from 'notionate/dist/components'
import { getServices } from '../lib/service'

type Props = {
  services: QueryDatabaseResponseEx
}

export const getStaticProps: GetStaticProps<Props> = async () => {
  const services = await getServices()
  return {
    props: {
      services,
    }
  }
}

const Home: NextPage<Props> = ({ services }) => {
  return <Gallery
    keys={['タイトル', '公開日時', 'タグ']}
    db={services}
    preview="cover"
    size="small"
    fit={true}
    href="/services/[Slug]"
    link={Link as React.FC<{ children: ReactElement<'a'>, href: string}>}
  />
}

export default Home

詳細ページは、Next.jsのルーティング作法に合わせてpages/services/[slug].tsx でファイルを作ります。内容は以下になります。Notionのページブロック情報を取得する FetchBlocks を使って取得したデータをブロックコンポーネントに渡します。そうすることで簡単にNotionのページが再現されます。

import { GetStaticProps, NextPage, PreviewData } from 'next'
import {
  FetchBlocks,
  ListBlockChildrenResponseEx,
} from 'notionate'
import {
  Service,
  build,
  getPaths,
  getService,
  DBPage,
  getServices,
} from '../../lib/service'
import { Blocks } from 'notionate/dist/components'

type Props = {
  service?: Service
  blocks?: ListBlockChildrenResponseEx
}

type Params = {
  slug: string
}

export const getStaticPaths = async () => {
  const paths = await getPaths()
  return {
    paths,
    fallback: false,
  }
}

export const getStaticProps: GetStaticProps<Props, Params, PreviewData> = async ({ params }) => {
  const service = await getService(params!.slug)

  if (!service) {
    return {
      props: {},
      redirect: {
        destination: '/404'
      }
    }
  }

  const blocks = await FetchBlocks(service.id)
  return {
    props: {
      service: build(service as unknown as DBPage),
      blocks,
    },
    revalidate: 60,
  }
}

const Service: NextPage<Props> = ({ service, blocks }) => {
  return (
    <>
      <header className="service-header">
        <div>
            <h2 className="name">{service?.title}</h2>
            <p className="meta">
                作成日: <span>{service?.date}</span>, 
                タグ: {service?.tags.map((tag, i) => (
                <span key={`${i}`}>{tag}</span>
                ))}
            </p>
        </div>
        <img className="cover" src={service?.cover} width="250px" />
      </header>
      <Blocks blocks={blocks!} />
    </>
  )
}

export default Service

スタイル周りを省略してますが、ここまで出来たらほぼ完成です。手元で yarn run するとサイトが確認できるでしょう。

GitHub RepositoryとWorkflowの準備

最後に、GitHubのActionsを使ってNext.jsのexportとその成果物をデプロイするためのworkflowを作ります。 .github/workflows/build.yml のパスで以下のファイルを作ります。mainブランチ以外では、ビルドのみを行い、mainブランチはscpでロリポップにデプロイを行うというものです。yarnのキャッシュなどを行った方が速くなると思いますが今回は省いています。

name: Build and Deploy

on:
  repository_dispatch:
  pull_request:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x]
    env:
      NOTION_CACHE: true
      NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
      NOTION_DBID: ${{ secrets.NOTION_DBID }}
    steps:
    - uses: actions/checkout@v3
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'
    - name: Run builds and deploys with ${{ matrix.node-version }}
      run: |
        yarn install && yarn build && yarn export
  deploy:
    runs-on: ubuntu-latest
    needs: [build]
    if: github.ref == 'refs/heads/main'
    env:
      NOTION_CACHE: true
      NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
      NOTION_DBID: ${{ secrets.NOTION_DBID }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    - name: Run next export
      run: |
        yarn install && yarn build && yarn export
    - name: deploy by scp
      uses: appleboy/scp-action@master
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USERNAME }}
        password: ${{ secrets.SSH_PASSWORD }}
        port: ${{ secrets.SSH_PORT }}
        source: "out/*"
        target: "web"
        strip_components: 1

ロリポップで提供されるドメインは web 直下のパスが固定となっているため、Next.jsが作る out ディレクトリを取り除くためのオプションである strip_components が trueにしています。さらに、GitHub ActionsのSecretを GitHubのリポジトリの設定から登録します。設定するのは、ローカルの .env に設定したNotionのAPIトークンやデータベースIDと、SCPのためのSSH接続情報です。SSH接続情報は、ロリポップのSSHページに掲載されています。

GitHub Actions Secrets

出来たものを gitコミットし、GitHubのリポジトリにPushします。すると、Workflowが動いてビルドとデプロイが行われました。

GitHub Actions Workflow

デプロイ先を見ると正しく機能していることがわかります。

Site Index

Site Page

Notionのコンテンツを更新した際は、GitHub ActionsのGUIから最新のDeployの Re-run ボタンを押すか、workflowに仕込んだhookである repository_dispatch をHTTP Postで発火させることでデプロイが行われます。残念ながら、今のところNotionに Outgoing-webhook的なことを設定できませんので、デプロイを自動化したい場合は、Google App Scriptなどを用いて監視しデプロイを自動でできるようにするなどの対応になると思います。普通に、 Re-run ボタンでいいと思いますが。

curl -X POST -H "Authorization: token <your-token>" \
  -H "Accept: application/vnd.github.everest-preview+json" \
  -d "{\"event_type\":\"Deploy the latest content on notion\"}" \
  -i  https://api.github.com/repos/<your-name>/<repo-name>/dispatches

ひと工夫

Next.jsはdefaultだとtrailing slash無しです。通常、Apacheを提供するホスティングサービスはtrailing slashになっているため、Next.jsがexportしたファイルとApacheのルーティングが合わずRootページ以外で404になってしまいます。Next.jsの設定でtrailing slashにすることはできますが、Apache側をtrailing slash無しの振る舞いになるように以下の内容で .htaccess を配置します。厳密に言うと、ロリポップのハイスピードプランは ApacheではなくApache互換なLitespeedです。また、合わせてHTTPをHTTPSにするルールも入れています。

DirectoryIndex index.html
DirectorySlash Off
ErrorDocument 404 /404.html
RewriteEngine On

# REWRITE to HTTPS
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

# REMOVE TRAILING SLASH
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [R=301,L]

# REWRITE to index.html
RewriteCond %{REQUEST_URI} "^/$"
RewriteRule ^(.*)$ /index.html [L]

# REWRITE to .html
RewriteCond %{REQUEST_URI} !index\.html$
RewriteCond %{REQUEST_URI} !^.*\.html$
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ %{REQUEST_FILENAME}.html [L]

まとめ

ロリポップとNotion、GitHubを使うことで、ハイパフォーマンスでHeadless CMSのセキュリティがNotionに担保されているという、もろもろがマネージドになり、運用トータルで超安価なサイトを作ることができました。私がNotionヘビーユーザとして一番のメリットは、Notion上で普段の作業からシームレスにサイトの記事を書けるというところです。

Notionには豊富な機能があり、それらが簡単に扱える様GUIのデザインがとても考えられて提供されています。そのため、Notionを扱うのはとても簡単なのですが、NotionのAPIを扱うとなると、途端にNotionの複雑性と対峙することになります。それらをNext.jsから扱うとなると、結構厄介な作業が必要になりますが、今回、 Notionateを使うことで手軽にNotionをHeadless CMSとしてサイトを作ることができました。Notionのデータベースは、カラムの型にバリエーションがあり、それらを全てAPIで操作することが可能です。Notionという素晴らしいプロダクトに隠されたフルマネージドなDatabase as a Serviceを使いこなし、安価で安全なサイトを作っていきましょう。

今回の成果物

作成したサイトとリポジトリは以下です。

元になった Notionは以下のリンク先です。

裏話になりますが、 Notionate は私が今回作ったパッケージです。バグや機能追加などがあればissueに報告お願いします!

また、合わせて作った Notionとホスティングサービスで使える 問い合わせフォームAPIは、後日、私の個人ブログで紹介する予定です。