😴

Nextjs と Firebase でブログ用CMS作る

2022/07/17に公開約5,000字2件のコメント

前から作ろうと思っていたブログ用のCMSを作ったので、まとめてみます。

構成

フロントエンド: Nextjs
データベース: Firestore
ストレージ:Cloud Storage
ユーザー認証:Firebase Authentication
状態管理:Recoil,swr
スタイル:styled-components
遷移アニメーション:framer-motion

なんでNextjs?

一番触れていたフレームワークであったことがかなり大きかったです。その中でも個人的に便利だったのはファイルベースのルーティングでした。ブログ投稿を動的にルーティングしてくれるDynamic Routingや、モック用のAPIやサーバサイドで定義したい処理を設定できるAPI Routeがデフォルトでサポートされており、公式ドキュメントに沿ってスムーズに実装ができました。

なんでFirebase?

ブログ用のCMSを作るにあたってサーバー側に必要な機能は、以下の内容があればとりあえず足りるかなと思っていました。

投稿保存用のDB
ユーザー認証機能
画像アップロード機能

上の機能を実現するにあたって、自分でバックエンドを構築・運用するのは正直面倒だなあと思い、今回は全てFirebaseにおまかせすることにしました。

投稿保存用のDB => Firestore
ユーザー認証機能 => Firebase Authentication
画像アップロード機能 => Cloud Storage

Firebase上のそれぞれの機能は、共通の認証情報でアクセスすることができるため、管理する情報が少なくて良いこともメリットでした。

機能1:予約投稿・リアルタイム編集


ブログを公開するタイミング時に、わざわざ手作業で公開設定するのが面倒なので予約投稿できるようにしました。できるようにしましたと言っても、公開時間公開設定を投稿のデータとして保存しているだけですが。。

import { StorageObject } from 'components/storage/types/storge-object'
import { Timestamp } from 'firebase/firestore'

// releaseが今より前 かつ publishがtrueの時のみ公開
export type Post = {
  id: string
  title?: string
  slug?: string
  publish: boolean
  release: Timestamp
  markdown?: string
  thumbnail?: StorageObject
}

投稿を取得する際は、CMS経由でないと取得できないようにします。これを実現するために、Firebase Authenticationの認証情報とFirestoreセキュリティルールを使用します。具体的な方法としては、Firebase Authenticationで管理しているユーザーにカスタムトークンとして権限情報を追加設定します。この権限情報をもとにFirestoreのCRUD権限を制御することで、CMSにログインしているかつ特定の権限を持つユーザーのみが投稿にアクセスできるようになります。

allow read,write: if request.auth.token.writer;
allow read,write: if request.auth.token.manager;
allow read,write: if request.auth.token.owner;

このままだとクライアントから投稿の取得ができないので、クライアントからもCMS経由で投稿を取得できるようにします。具体的には、Nextjsにてクライアント取得用のAPI Routeを定義し、そのエンドポイントにアクセスする形式で実装しました。CMSとクライアント取得用エンドポイントは別の場所においた方が安全な気がしているため、ここは要検討です。。

クライアントから取得する際は、 CMSにログインできない前提があるため、先ほどのセキュリティルールに引っ掛かって投稿を取得できません。これを回避するために、Firebase Admin SDKを利用して投稿を取得できるようにします。Firebase Admin SDKを利用すれば、セキュリティルールを無視して投稿を取得できるため、CMSへのログインをしなくても問題なく取得できます。

export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'GET') {
    if (admin.apps.length === 0) {
      admin.initializeApp({
        credential: admin.credential.cert({
          projectId: process.env.FIREBASE_PROJECT_ID,
          privateKey: (process.env.FIREBASE_PRIVATE_KEY as string).replace(/\\n/g, '\n'),
          clientEmail: process.env.FIREBASE_CLIENT_EMAIL
        })
      })
    }

    const query = req.query
    const slug = query.slug as string
    const db = admin.firestore()
    const now = new Date()

    const docPost = await db.collection('post')
      .where('slug', '==', slug)
      .where('publish', '==', true)
      .where('release', '<', now)
      .get()

    if (docPost.empty) {
      return res.status(404).json({ message: 'Post is not found' })
    }

    const post = docPost.docs[0].data() as Post

    return res.status(200).json({
      id: post.id,
      title: post.title,
      slug: post.slug,
      release: post.release.toDate(),
      markdown: post.markdown,
      thumbnail: post.thumbnail
    })
  }
}

リアルタイム編集については特別な機能はなく、スイッチで切り替えることでマークダウンのプレビューが見れます。useStateを利用して切り替えています。また、マークダウンのパーサーは正規表現やアルゴリズムの勉強も兼ねて自分で作ってみました。独自のコンポーネントをマークダウン記法でかけたら便利そうと思ったので、そのうち実装してみたいです。忘れないうちに備忘録をいつか書きたいと思います。

機能2:画像アップロード機能

Zennの画像アップロード機能がめちゃ便利だったので、再現したいという思いから作ってみました。機能としては、CloudStorageに画像をアップロード>アップロード先をもとにマークダウンを作成&挿入という2つの機能を目指しましたが、個人的に悩んだのはユーザーがどこに画像用のマークダウンを組み込みたいのかという点でした。というのも、ユーザーが画像をアップロードする際はテキストエリアからフォーカスが離れるため、ユーザーがどこに画像用マークダウンを挿入するのか、意識が離れるのかもと意味があるか分からない深読みをしていたからです。少し考えて、以下のように落ち着きました。

1. テキストエリアに1度でもフォーカスした場合...最後にフォーカスした行の次の行に挿入する
2. テキストエリアに1度もフォーカスしていない場合...末尾(1文字以上ある行に限る)の次の行に挿入する

無意識に快適ゆえ、いざ自分で作るとなると色々迷いますね。。勉強になりました。

機能3:ユーザー認証

誰彼構わず投稿をいじれたら色々まずいので、Firebase Authenticationを使用して認証機能をつけます。基本的に公式のドキュメント通りに実装をして、追加でカスタムトークンを設定します。カスタムトークンを設定することで、ユーザー別の権限を付与したり、いろんな属性をつけることができます。

const setUserClaim = async (uid: string, customClaims: UserClaim) => {
    try {
      await admin.auth().setCustomUserClaims(uid, customClaims)
    } catch (error) {
      if (process.env.NODE_ENV === 'development') {
        console.log(error)
      }
    }
  }

作ってみて

以上の実装で基本的なブログ用のCMSとしての機能は果たすことができるようになりました。作り始める時に、本当にシンプルに作る! と目標立てたので、本当に他の機能はないです。笑

Firebaseは初めて使用したのですが、とてもシンプルにいろいろなサービスを触ることができました。他に作りたいものが出てきた時は、また使用してみたいと思います。

今後はオリジナルのコンポーネントをマークダウンに加えたり、投稿の他にオブジェクトを管理できるようにしたり、作りたい機能を足していきたいと思います。

参考文献

https://nextjs.org/docs/getting-started
https://firebase.google.com/docs/build?hl=ja
https://www.m3tech.blog/entry/2021/08/23/124000
https://zenn.dev/mast1ff/articles/a71ece42905c74

Discussion

無知で申し訳ないのですが、api routeのapiをエンドポイントにするとどのようなurlになるのでしょうか?

また、その場合セキュリティなどどのようになっているのでしょうか?

質問ありがとうございます!
Nextjsでは、プロジェクト内のpages/apiというディレクトリ配下にファイルを配置すると、その配置場所を API のエンドポイントとして認識するようになります。
例えば、pages/api配下にblog.tsというファイルを配置すると、/api/blogというエンドポイントが作成されます。そして、/api/blog宛にリクエストを送ると、先ほど配置したblog.tsの内容を実行するようになります。

動作環境: http://localhost:3000
作成したファイル: pages/api/blog

エンドポイント:http://localhost:3000/api/blog

https://nextjs-ja-translation-docs.vercel.app/docs/api-routes/introduction

セキュリティについては、Firebaseにおけるセキュリティルールという機能を使用することで制限をしています。セキュリティルールは、この条件を満たせばデータを取って良し! という許可形式で書くことができます。私の場合は、上の記事のようにCMSを利用している特定のユーザーのみデータが取得・更新できるように設定をしていました。

https://firebase.google.com/docs/firestore/security/get-started?hl=ja

CMSのユーザー以外がブログ記事を取得する際は、上記のセキュリティルールに引っ掛かって取得ができないので、Firebase Admin SDKを利用します。Firebase Admin SDKは、特権環境として強い権限を持って操作することができるため、先ほど紹介したセキュリティルールもすっ飛ばしてデータの取得・更新を行うことができます。
そのため扱うには注意する必要がありますが、今回は投稿の取得のみを API Route に定義することでその機能を限定しています。
こうすることで投稿取得・更新の可能な範囲は以下の通りとなります。

投稿の更新 => CMSを利用しているユーザーのみ
投稿の取得(公開していないものも含む) => CMSを利用しているユーザーのみ
投稿の取得(公開しているもののみ) => CMSを利用しているユーザー・クライアント両方

https://firebase.google.com/docs/admin/setup?hl=ja

上の記事で話していた

CMSとクライアント取得用エンドポイントは別の場所においた方が安全な気がしているため、ここは要検討です。。

という話は、「クライアントから投稿を取得する時に、取得先つまりCMSのドメインがバレるのであまり宜しくないよね・・」という話になります。(分かりづらくて申し訳ないです。。)ドメインがバレればログイン画面に辿り着いてログインされる可能性もあるため、できるなら別のサーバーを立ててそこにアクセスする方が余計な心配をしなくて良いということです。

ログインするとコメントできます