HeadlessCMSやNext.jsのSSR/SSGの知見を得るべく、ContentfulのGraphQL Content APIからデータ取得して、Next.jsでサイトを作成してみました。
一連の作業ログをまとめましたので、今後同じようにサイトを作成したい人は参考にしてください。
作成したものはGithub Pagesにデプロイしているので閲覧いただけます。
また、ソースコード郡はこちらを参考にしてください。
作業の流れ
実際に作業した流れとしては、下記のとおりになります。
- 作業手順・内容の調査・確認
- 環境構築
- Next.jsの導入
- Typescriptの導入
- Dockerの導入
- TailwindCSSの導入
- ESLintの導入
- huskyの導入
- Contentful上でコンテンツ作成
- アカウント作成
- コンテンツモデルの作成
- コンテンツの作成
- locale設定
- GraphQL Content APIでのコンテンツ取得動作確認
- GraphQL Content APIへの対応
- ApolloClientの導入
- schema取得
- graphql-codegenで型生成
- Next.jsでのサイト作成
- local-only field対応
- SSG対応
- Google Tag Manager対応
- Github Actionsの作成・デプロイ
作業手順・内容の調査・確認では、作業の概観を把握するためにいろいろな記事から作業内容を調査しました。各項目ごとに参考にした記事URLを貼っておきます。
では、1つずつ作業内容を記していきます。
環境構築
ローカル環境のyarn, nodeのバージョンは以下のとおりです。
$ yarn -v
1.22.10
$ node -v
v14.16.1
まずは.gitignore
をこちらのサイトからmacos,emacs,vim,node,yarn
で作成します。
Next.jsの導入
Next.jsの導入はこちらを参考にしました。
$ yarn init -v
# package.jsonにてprivate: true を入れておく
$ yarn add -E react react-dom next
続いて、package.jsonに以下のスクリプトを追加します。
...
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
...
Next.jsはpages
ディレクトリを参照してページを作成するので、適宜pages
ディレクトリを作成して、yarn run dev
で動作確認します。
$ mkdir src/pages
$ yarn run dev
その後、localhost:3000にアクセスして、404ページが出力されることを確認しました。
Typescriptの導入
$ yarn add -D typescript @types/react -E
$ npx tsc --init
tsconfig.json
はContentfulのNext.js対応したGithubレポジトリのものを参考にしました。
Docker導入
Dockerfile
とdocker-compose.yml
を作成します。
FROM node:14.16.1-alpine
ENV TZ=Asia/Tokyo
WORKDIR /app
COPY package.json /app
COPY yarn.lock /app
RUN yarn install
COPY . /app
CMD ["yarn", "dev"]
version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
command: 'yarn run dev'
ports:
- 3000:3000
volumes:
- .:/app:cached
- node_modules:/app/node_modules
tty: true
stdin_open: true
environment:
# 適宜環境変数を追加していく
volumes:
node_modules:
node_modules
等、イメージをビルドする際に処理が遅くなってしまうようなファイル郡は.dockerignore
で除外します。
node_modules
yarn-error.log
.husky
.next
.git
out
その後、$ docker-compose up --build
を実行した後、localhost:3000にアクセスして404ページを閲覧できるのを確認しました。
TailwindCSSの導入
CSSでのマークアップはTaliwindCSSを使いました。CSSプロパティがクラス名とマッピングされており、私個人としては使いやすい印象を持っています。都度コンポーネントのクラス名を考えなくて良いのもポイントです。
導入にあたっては、TailwindCSSの公式サイトを参考にしました。
$ yarn add -D -E tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
module.exports = {
mode: 'jit',
purge: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
}
また、デプロイ時にCSSファイルをminifyするべく、cssnano
を導入し、postcss.config.js
を修正しました。
$ yarn add -D -E cssnano
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {}),
},
}
各コンポーネントへTailwindCSSの適用は、Next.jsでpages
ディレクトリ直下に作成する_app.tsx
にてimport 'tailwindcss/tailwind.css'
を記載します。詳しくはこちらを参考にしてください。
ESLintの導入
コードの見栄え、可読性を統一するため、こちらも公式サイトを参考にしてESLintを導入していきます。
$ yarn add -E -D eslint eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-tsc eslint-plugin-tailwindcss @typescript-eslint/parser @typescript-eslint/eslint-plugin prettier eslint-config-prettier eslint-plugin-prettier
$ npx eslint --init
その後、各種プラグインの設定やルールを.eslintrc.json
に記載していきます。ESLintから除外したいファイルがある場合は.eslintignore
に記載します。
{
"env": {
"browser": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "react-hooks", "tailwindcss", "tsc"],
"settings": {
"react": {
"version": "latest"
}
},
"rules": {
"eol-last": ["error", "always"],
"react/prop-types": [0],
"tailwindcss/classnames-order": 2,
"tailwindcss/no-custom-classname": 2,
"tailwindcss/no-contradicting-classname": 2,
"prettier/prettier": [
"warn",
{
"tabWidth": 2,
"semi": false,
"singleQuote": true
}
]
}
}
node_modules
.next
next.config.js
out
ESLintを設定した後にpackage.json
のscriptsに"lint": "eslint --ext .js,.jsx,.ts,.tsx,.json ."
を追加した後に、$ yarn run lint --fix
してルールを適用しました。
huskyの導入
gitのコミット時にlintを走るよう、husky, lint-stagedをセットアップしていきます。
$ yarn add -D -E husky lint-staged
$ npx husky install
huskyのドキュメントではnpm set-script
を使うと書かれていますが、npm7系からのコマンドだったため、自前で導入していきます。package.json
のscriptsに"prepare": "husky install"
を追加しました。
$ npx husky add .husky/pre-commit "npx lint-staged"
でhusky側の対応した後、package.json
にlint-stagedの対応していきます。
...
"lint-staged": {
"*.{ts,tsx,json}": "yarn run lint"
}
...
以上が開発環境の構築になります。
Contentful上でコンテンツ作成
Contentfulを初めて触るので、いろいろと下調べをしました。以下が参考にした記事URLになります。
- https://satoruakiyama.com/blog/building-blog-with-nextjs-and-contentful-ja
- https://www.contentful.com/developers/docs/references/graphql/
アカウント作成
Contentfulにアクセスして、アカウントを作成します。自分はGithubアカウントでSIGN UPしていきます。
指示に従って、入力・規約の確認を行い、アカウントを作成します。今回はフリープランで紹介していきます。
その後、Organization名を入力し、spaceを作成します。個人作成の場合は個人だとわかるよう、Organization名を設定し、space名は使用するサイト名などにしておくと把握しやすいかと思います。
私の場合は、Organization: Adacchi3, space: adacchi3 portfolioにしました。
コンテンツモデルの作成
コンテンツを作成するにあたり、コンテンツモデルを作成していきます。いわゆるDBスキーマ作成のようなものになります。下記画像のヘッダー部分にあたるContent modelをクリックします。
新規コンテンツモデルを作成する場合は、下記画像右上にあるAdd content typeをクリックします。
コンテンツタイプ名はPost
やUser
のようなクラスに該当する名称になります。下記画像の場合はAchievement
になります。コンテンツタイプのフィールド(プロパティ・属性に該当するもの)を追加していく場合は下記画像の右上にあるAdd fieldを選択していきます。
フィールドに選択できる型としてはShort textやDate & Time, Integerと幅広い種類があります。また、Short textの中でもURLといったようなバリデーションをつけることができます。
フィールドの型の1つにReferenceというものがあります。こちらはいわゆるコンテンツとコンテンツを関連させることができ、has_on, has_manyの関連設定もできます。バリデーションを設定しない場合はあらゆるコンテンツタイプと紐付けることができますが、制限したい場合は下記画像のような設定項目によって一意のコンテンツタイプとの関連を設定することができます。
コンテンツの作成
続いて、コンテンツ(エントリー)を作成していきます。いわゆる、レコード・インスタンスに該当するデータそのものになります。下記画像が示すヘッダーのcontentをクリックします。
エントリーを新規作成する場合は右上にあるAdd Entryをクリックし、コンテンツタイプを選択し、データを入力していきます。
入力フォームは下記画像のとおりです。Referenceと設定したフィールドでは、Add existing contentを選択することで、すでに作成済みのコンテンツを選ぶことができます。また、NEW CONTENTの下にあるコンテンツタイプを選択することで、新規作成もできます。
Referenceをhas_manyにした場合は、コンテンツを複数選択することができます。その際、選択したコンテンツの順序を入れ変えることができ、データ取得時もその順序は引き継がれます。
STATUSはそれぞれDraft, Changed, Published, Archivedの4つがあり、preview時のみ閲覧できるコンテンツ、本番反映するコンテンツなどの設定ができます。
locale設定
Contentfulではコンテンツの多言語対応もできます。ここでは、デフォルトの言語設定を見ていきます。下記画像のヘッダーから設定を選び、Localesをクリックします。
デフォルトになっている言語をクリックします。
プルダウンメニューからお好みの言語を選択してください。デフォルトでは、Locale settingsは変更できないですが、オプションで追加する言語については設定を見直すことができます。フリープランでは2言語まで対応しているようです。
GraphQL Content APIでのコンテンツ取得動作確認
作成したコンテンツが実際に取得できるかをGraphQL Content APIを通じて確認していきます。
はじめに、API keyを作成していきます。
Content delivery/preview tokensとContent management tokensの2つ作成することができます。今回はデータ取得のため、Content delivery/preview tokensのタブを選択した後に、Add API keyをクリックします。
作成すると、SpaceIDとContent Delivery API, Content Preview APIの3つ取得できます。Contentfulの公式GraphQLドキュメントで示されているCDA_TOKENはContent Delivery API - access tokenのことを指しています。Content Delivery API, Content Preview APIの違いとしては、publishedステータスのコンテンツのみが取得できるか・preview設定を有効にした際にDraftのコンテンツを取得できるかどうかだと思われます。開発環境(またはプレビュー)と本番環境での使い分けをしていく必要がありそうです。
発行したCDA_tokenを使って、Contentfulが提供しているGraphiQLにアクセスしてみます。URLはhttps://graphql.contentful.com/content/v1/spaces/{SPACE}/explore?access_token={CDA_TOKEN}
と適宜SPACEとCDA_TOKENを置換してください。
GraphiQLで表示されるドキュメントを確認しながら、エディタ上でクエリを作成して実行してみてください。
Chrome拡張のAltairなどで確認したい場合はGraphQLのエンドポイントをhttps://graphql.contentful.com/content/v1/spaces/{SPACE}/environments/{ENVIRONMENT}?access_token={CDA_TOKEN}
を指定すると確認できます。
以上で、Contentful上でのコンテンツ作成は完了です。
GraphQL Content APIへの対応
Next.jsからContentfulのGraphQL Content APIからデータ取得できるよう対応していきます。
以下の記事URLを参考にさせていただきました。
- https://www.apollographql.com/docs/react/get-started/
- https://github.com/vercel/next.js/tree/canary/examples/with-apollo
- https://github.com/apollographql/apollo-tooling
- https://qiita.com/koedamon/items/0fd3a01f7e398e54b747
- https://www.graphql-code-generator.com/docs/getting-started/installation
- https://qiita.com/mizchi/items/fb9f598cea94d2c8072d
ApolloClientの導入
ApolloClientの導入自体はApolloClientの公式ドキュメントを参考にしながら作業しました。また、Next.jsの公式レポジトリにApollo対応した実装例があるため、コード自体はそちらを参考にしています。
Next.jsのSSGを用いる場合、Next.jsの公式レポジトリのApollo対応した実装例を参考にすることが必要になります。実装例ではキャッシュ機構を有効に活用した実装になってます。getStaticProps
上で取得したクエリ結果をキャッシュし、それぞれのコンポーネントでクエリを発行した場合はキャッシュしたクエリ結果を使うようになっているようです(参考)。
schema取得
今回はTypescriptでの実装であるため、Contentfulで作成したコンテンツタイプの型定義をする必要があります。ただ、自前で作成するのはコンテンツタイプの量と比例して作業時間も増えてしまうため、自動で生成できるようにしていきます。
本来であればgraphql-codegen
でバシっと作るのが理想ですが、クエリを更新するたびにGraphQL Content APIにアクセスするのが忍びなかったため、apollo toolでschemaをダウンロードすることにしました。
$ yarn add -D -E apollo
した後、下記のファイルを作成します。.env
にGraphQL Content APIのURLを記載しておきます。
module.exports = {
client: {
name: 'client',
includes: ['src/graphql/schema/**/*.{ts,tsx,graphql}'],
excludes: [
'src/graphql/queries/*.{ts,tsx,graphql}',
'src/graphql/generated/*.{ts,tsx,graphql}',
'src/graphql/graphql.schema.json',
],
addTypename: true,
service: {
name: 'contentful graphql endpoint',
url: process.env.GRAPHQL_ENDPOINT,
},
},
}
package.json
のscriptsに"apollo:download": "apollo client:download-schema src/graphql/schemas/contentful/schema.graphql"
を追加します。
その後は以下のコマンドを実行して、schemaを取得しました。
$ docker-compose run --rm app /bin/sh
/app # yarn run apollo:download
/app # exit
graphql-codegenで型生成
まずはgraphql-codegenを導入していきます。init
の際にいろいろと質問されますが、該当する選択肢を選べば大丈夫だと思います。
$ yarn add -D -E @graphql-codegen/cli
$ yarn graphql-codegen init
生成されたcodegen.yml
を以下のように修正しました。
overwrite: true
schema: "src/graphql/schemas/**/schema.graphql"
documents: "src/graphql/queries/*.graphql"
generates:
src/graphql/generated/graphql.tsx:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"
config:
withHOC: false
withHooks: true
withComponents: false
src/graphql/graphql.schema.json:
plugins:
- "introspection"
その後、$ yarn run generate
でinit
時に追加されたscriptが実行され、型が生成されます。
生成されたgraphql.tsx
のtype Scalars
にDateTime
やDimension
があり、any
で型定義されています。any
を許容しない場合は、説明文を参考にしてstring
やnumber
に変更すると良いかもしれません。
Next.jsでのサイト作成
Next.jsでpagesやcomponentsを作成していきます。ここでは留意した点を記載していきます。
local-only field対応
今回のサイト要件では、多言語対応(日本語・英語)に対応する必要がありました。Next.jsにはlocalesの機能がありますが、SSGには対応していなかったため、自前で対応することになりました。
ApolloClientにはlocal-only fieldの機能があり、キャッシュ機能を用いてローカルの状態を管理することができます(Apollo公式ドキュメント)。
ApolloClientを設定しているファイル上に下記のような実装を施しました。makeVar
で管理したい状態(今回の場合は多言語設定)を定義し、InMemoryCache
ではfieldからlocaleを呼び出した際にmakeVar
で定義した状態を参照するよう設定します。
import { ApolloClient, HttpLink, InMemoryCache, makeVar } from '@apollo/client'
...
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
locale: {
read() {
return localeVar()
},
},
},
},
},
})
...
export const localeVar = makeVar('ja-JP')
次にローカルの状態を参照するGraphQLのクエリ/スキーマを作成し、$ yarn run generate
を実行して型生成します。
query Locale {
locale @client
}
type Query {
locale: String!
}
その後、localeの状態を参照できるようカスタムフックを作成しました。
import { useLocaleQuery } from '@graphql/generated/graphql'
export const useLocale = (): string => {
const { data: localeData } = useLocaleQuery()
return String(localeData?.locale)
}
状態の変更は各ページにて、localeVar('en-US')
等で変更しています。今回のページ構成では、/
は日本語、/en/
は英語で設計したため、このような仕様で落ち着きました。
SSG対応
ホスティングサービスをこれまで通り、Github Pagesを使おうとするとSSRやISRは対応できず、SSG一択になりました。(SSRやISRを使う場合はVercelがおすすめらしいです)
Next.jsのSSGする場合、各種fetchするデータはgetStaticProps
内に処理を記述する必要があります。処理自体はNext.jsの実装例を参考にしながら作成していきました。
ここの詳細は検索するといろいろと出てきますので、SSGの実装内容については割愛します。
今回はGithub Pagesにデプロイするため、実際に生成されるSSGを確認するために、serve
を導入して動作確認を行いました(ここの動作確認はDocker上ではなくローカル環境上で行ってました)
$ yarn add -D -E serve
を実行し、package.jsonのscriptsに下記のスクリプトを追加しました。
...
"export": "next export",
"serve": "yarn run build && yarn run export && serve ./out",
...
その後、$ yarn run serve
を実行することで、localhost:5000で実際にSSGされたページにアクセスすることができます。
next build
時には以下のようなログが確認することができました。SSGされたページの確認等ができるのが良い点だなと思いました。
Page Size First Load JS
┌ ● / 525 B 214 kB
├ /_app 0 B 115 kB
├ ○ /404 3.29 kB 118 kB
├ ○ /articles 1.09 kB 131 kB
├ ● /articles/[slug] 1.16 kB 131 kB
├ ├ /articles/hoge
├ └ /articles/fuga
└ ● /en 527 B 214 kB
+ First Load JS shared by all 115 kB
├ chunks/408.7f9089.js 49.1 kB
├ chunks/commons.4d71e3.js 13.2 kB
├ chunks/framework.9c2f4b.js 42 kB
├ chunks/main.7c63f6.js 7.16 kB
├ chunks/pages/_app.712491.js 2.02 kB
├ chunks/webpack.e76463.js 1.14 kB
└ css/1d7a68075e1fa382472f.css 2.57 kB
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
(ISR) incremental static regeneration (uses revalidate in getStaticProps)
GTM対応
サイトの計測のため、Google Tag Managerを導入しました。
こちらの記事URLを参考にreact-gtm-module
を導入し、_app.tsx
内にて設定しました。
import React, { useEffect } from 'react'
import { AppProps } from 'next/app'
import { ApolloProvider } from '@apollo/client'
import { useApollo } from '@client'
import 'tailwindcss/tailwind.css'
import TagManager from 'react-gtm-module'
const MyApp: React.FC<AppProps> = ({ Component, pageProps }: AppProps) => {
const apolloClient = useApollo(pageProps)
useEffect(() => {
TagManager.initialize({ gtmId: String(process.env.GTM_ID) })
}, [])
return (
<ApolloProvider client={apolloClient}>
<Component {...pageProps} />
</ApolloProvider>
)
}
export default MyApp
Single Page ApplicationのPage Viewを計測するためには、GTM上のトリガーで「履歴の変更」を作成し、適用する必要があります。こちらは「SPA GTM」で検索すると記事がヒットするのでご確認ください。
Github Actionsの作成・デプロイ
こちらの記事URLを参考にNext.jsをbuild, exportして、gh-pagesにpushするGithub Actionsを作成しました。
name: deploy github page
on:
push:
branches:
- master
jobs:
deploy:
name: next build, export and deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v2
with:
node-version: '14'
- name: get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: cache dependencies
uses: actions/cache@v2
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: install dependencies
run: yarn install --prefer-offline
- name: build
env:
GRAPHQL_ENDPOINT: ${{secrets.GRAPHQL_ENDPOINT}}
AUTHOR_ID: ${{secrets.AUTHOR_ID}}
GTM_ID: ${{secrets.GTM_ID}}
run: yarn run build
- name: export
run: yarn run export
- name: add nojekyll
run: touch ./out/.nojekyll
- name: deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./out
このとき、環境変数はSetting→Secrets→New repository secretで設定します。
上記の設定によって、masterにpushした際にgh-pagesにSSGされたファイル郡がpushされるようになりました。
Github Pagesの設定で、Source branchをgh-pagesに変更し、ディレクトリを/(root)
にすることで、gh-pagesのファイル郡を参照してくれるようになります。
まとめ
今回はContentful, Next.js, ApolloClient, TailwindCSSを使って、ポートフォリオサイトを作成し、Github ActionsでGithub Pagesに公開するまでの流れを示しました。
所感としては以下のとおりです。
- webpack.config.jsを書く覚悟でいたが、Next.js側でよしなにしてくれてとても助かった
- Webpack5で動いてました、すごい!
- Next.jsでのSSG/SSR/ISRの対応方法をだいたい把握することができた
- ApolloClientのlocal-only fieldの概要を掴むことができた
- 状態管理がApolloClientで一括管理できるのは良い
- graphql-codegenが使いやすかった
- Contentfulのコンテンツタイプの定義が難しい
- 何も考えずに作ると無法地帯になりそう
- ちゃんとモデル設計するのがベター
- GraphQL Content APIから取得したスキーマからgraphql-codegenで生成した型が使いづらかった
-
Maybe<string> | undefined
等で自身のコードがところどころ型定義の良さを使い越せなかった印象
-
ご参考までに。