Jamstackなサイト制作（Next.js×Sanity）

Next.jsを動画教材を使って学んだので、アウトプットとして自分のポートフォリオサイトを作ってみた。前々からJamstackというアーキテクチャが少し気になっていたので実装してみた。

https://shihosoi.com/

# Jamstackとは
Web開発アーキテクチャのひとつ。
- JavaScript
- API
- Markup（HTML）

上記3つの単語の頭文字をとって、NetlifyのCEOであるMathias Biilmann氏が作った略語。

## Jamstackのメリット
### 高パフォーマンス
Jamstackでは閲覧者の要求に対して静的ページを返すだけなので、レスポンスが早く、アクセスが極端に集中してもパフォーマンスが落ちにくい。
また、Google公式のSEO基礎にも「ウェブサイトのコンテンツにどのデバイスからでも速く簡単にアクセスできるか？」との記載があるため、SEOにも効果的。

https://developers.google.com/search/docs/fundamentals/get-on-google?hl=ja#is-your-content-fast-and-easy-to-access-on-all-devices

### UX向上
レスポンスが早い（ページ表示が早い）ので、ストレスフリー。

### 高いセキュリティ
Jamstackは動的なコンテンツ生成をなくし、静的ファイルをユーザーに返却しているため、閲覧者から見るとウェブサーバーが存在していない。
攻撃の糸口となる対象が見つからないことで、安全性を保つことが比較的容易になる。

## 従来のCMS使うデメリット
### 表示速度が遅い
アクセスのたびにプログラムが動きWebページが作成される「動的サイト」であるため、処理の時間が発生し、Jamstackなサイト（静的サイト）に比べて表示速度が遅い。

### セキュリティに弱い
世界で最も多く利用されているCMSであるWordPressを例に挙げると、`https://www.example/wp-admin`とアドレスバーに入力するとログイン画面にアクセス出来ることが多々ある。そのため、ログインIDやパスワードを盗めれば外部からもログインできてしまい、個人情報などの入手やコンテンツの改ざんなどもできてしまう。
また、利用者がそもそも多いのでハッカーの攻撃対象になりやすい。


# 使用技術
Jamstackを実装するために必要なものは、静的サイトジェネレーター（HTMLを生成するライブラリ）、ヘッドレスCMS（API）、ホスティングサービス（Netlify、Vercelなど）である。
なので下記技術を使うことにした。

- Next.js（静的サイトジェネレーター）
- Sanity（ヘッドレスCMS）
- Vercel（ホスティングサービス）
- TypeScript（型定義）
- Tailwindcss（スタイリング）


# アーキテクチャ
![Frame 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2638572/80b0233b-ed34-514e-129a-48fb8fef4d9f.png)
コードをGithubにPushするとVercelが自動でビルドとデプロイを行なってくれる。
また、VercelのDeploy Hookを利用し、QiitaとSanityにコンテンツの追加や更新、削除があった時にWebhookを使い、VercelのエンドポイントにPOSTリクエストを送信し、自動でビルドとデプロイを行う。
自分が書いたQiitaの記事は、Qiita APIを使用しデータを取得した。




# 実装
## フロントエンド（Next.js）
### ページ構成
```
/pages
    ├─index.tsx
    ├─　/profile
    │   ├─　index.tsx
    ├─　/workｓ
    │   ├─　index.tsx
    │   └─ [id].tsx
    ├─　/article
    │   └─　index.tsx
    └─　４０４.tsx
```

<table>
  <thead>
    <tr>
      <th>ファイル名</th><th>説明</th>
    </tr>
  </thead>
  <tr>
    <td>index.tsx</td> <td>トップページ</td>
  </tr>
  <tr>
    <td>/profile/index.tsx</td> <td>プロフィール</td>
  </tr>
  <tr>
    <td>/works/index.tsx</td> <td>過去の制作実績一覧</td>
  </tr>
<tr>
    <td>/works/[id].tsx</td> <td>過去の制作実績詳細（ダイナミックルーティング）</td>
  </tr>
 <tr>
    <td>/article/index.tsx</td> <td>Qiita記事一覧</td>
  </tr>
 <tr>
    <td>404.tsx</td> <td>404エラー</td>
  </tr>
</table>
Workページの制作実績とプロフィールページにある自分のスキルセットをCMSで管理することにした。

### ベースレイアウト

```javascript:Layout.tsx
import { ReactNode } from "react";
import Footer from "./Footer";
import Header from "./Header";
import Meta from "./Meta";

type Props = {
  children: ReactNode;
};

const Layout = ({ children }: Props) => {
  return (
    <>
      <Meta />
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
};

export default Layout;
```
全ページ共通のコンポーネントで、メタ情報、ヘッダー、メインコンテンツ、フッターのオーソドックスな構成です。`children`に`<Layout></Layout>`で囲った要素が渡ってきます。

```typescript
const Example = () => {
  return (
    <Layout>
      テストページです。
    </Layout>
  );
};

export default Example;
```
こうすると・・・

```typescript
const Layout = ({ children }: Props) => {
  return (
    <>
      <Meta />
      <Header />
      <main>テストページです。</main>
      <Footer />
    </>
  );
};
```
こうなる。

### ページごとにメタタグを出し分け
ページごとにメタタグの内容を変更することもNext.jsでは簡単にできる。

```typescript:/components/common/Meta.tsx
import Head from "next/head";
import { useRouter } from "next/router";

const Meta = () => {
  const router = useRouter();

  return (
    <Head>
      <meta charSet="utf-8" />
      <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
      <meta name="keywords" content="エンジニア,ポートフォリオサイト" />
      <meta name="format-detection" content="telephone=no" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta
        property="og:type"
        content={router.pathname === "/" ? "website" : "article"}
      />
      <meta
        property="og:image"
        content={`${process.env.NEXT_PUBLIC_BASE_URL}/share.jpg`}
      />
      <meta property="og:site_name" content="Shiho's Portfolio" />
      <meta property="og:locale" content="ja_JP" />
      <meta name="twitter:card" content="summary_large_image" />
      <link
        rel="shortcut icon"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/favicon.ico`}
        type="image/x-icon"
      />
      <link
        rel="apple-touch-icon"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/apple-touch-icon.png`}
      />
      <link
        rel="apple-touch-icon-precomposed"
        sizes="120x120"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/apple-touch-icon.png`}
      />
      <link
        rel="apple-touch-icon-precomposed"
        sizes="144x144"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/apple-touch-icon.png`}
      />
      <link
        rel="apple-touch-icon-precomposed"
        sizes="152x152"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/apple-touch-icon.png`}
      />
      <link
        rel="start"
        href={`${process.env.NEXT_PUBLIC_BASE_URL}/`}
        title="Shiho's Portfolio"
      />
    </Head>
  );
};

export default Meta;

```
これが全ページ共通で、ページごとにタイトルやディスクリプション、その他情報を追加する場合は、

```typescript:/pages/works/index.tsx
type Props = {
  works: Work[];
};

const Index = ({ works }: Props) => {
  const meta = {
    title: "Works | Shiho's Portfolio",
    description: "過去の製作物を紹介しています。",
    url: `${process.env.NEXT_PUBLIC_BASE_URL}/works`,
  };

  return (
    <>
      <Head>
        <title>{meta.title}</title>
        <meta name="description" content={meta.description} />
        <meta property="og:title" content={meta.title} />
        <meta property="og:description" content={meta.description} />
        <meta property="og:url" content={meta.url} />
        <link rel="canonical" href={meta.url} />
      </Head>
      <Layout>
        ・
        ・
        ・
      </Layout>
    </>
  );
};
```
このように`<Head>`タグの中に記述することで、ページごとにメタ情報を出し分けることができる。

### SSGとダイナミックルーティング
`/works/[id].tsx`の製作実績詳細ページを静的生成（SSG）する。SSGに関しては下記参照。

https://zenn.dev/rinda_1994/articles/e6d8e3150b312d#ssg

ブログなど動的に変化するページを静的生成するためには`[id].tsx`の`[id]`に入る一意の値をAPIから（今回だとSanityから）取得する必要があり、`GetStaticPaths`を使用する。
取得したIDを`GetStaticProps`メソッドに渡し、IDと合致する記事情報をAPIから取得しレンダリング行う。

#### コード

```typescript:/work/[id].tsx
import { GetStaticPaths, GetStaticProps } from "next";
import { fetchWorks, fetchWorkData } from "utils/work/fetchWork";

type Props = {
  work: Work[];
};

const WorkId = ({ work }: Props) => {
    return (
        <div>
            {work._id}
            {work.title}
            {work.description}
        </div>
    );
};

export default WorkId;

export const getStaticPaths: GetStaticPaths = async () => {
  const works = await fetchWorks();
  const paths = works?.map((work) => {
    return { params: { id: work._id } };
  });
  return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const work = await fetchWorkData(params?.id as string);
  return {
    props: {
      work,
    },
  };
};
```

#### 解説
```typescript
const works = await fetchWorks();
```
`fetchWorks`メソッドで製作実績を全件取得し、変数に格納。
`fetchWorks`メソッドは`utils/work/fetchWork.tsx`に作成したSanityからデータを取得するためのメソッド。

```typescript
const paths = works?.map((work) => {
    return { params: { id: work._id } };
  });
return { paths, fallback: false };
```
取得したデータから`id`を取得するため、`map`で新たに配列を作成後、変数に格納し、`return`することで`getStaticProps`メソッドの引数に渡ってくる。
`fallback: false`とすることでパスが見つからない場合、`/pages/404.tsx`を設置することでオリジナルの404ページを表示できる。

```typescript
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const work = await fetchWorkData(params?.id as string);
  return {
    props: {
      work,
    },
  };
};
```
`getStaticPaths`から取得した`id`に紐付くデータを`fetchWorkData`メソッドにより取得し、変数に格納。`return`で関数コンポーネントに`props`として渡す。
`fetchWorkData`メソッドは`utils/work/fetchWork.tsx`に作成したSanityからデータを取得するためのメソッド。

```typescript
const WorkId = ({ work }: Props) => {
    return (
        <div>
            {work._id}
            {work.title}
            {work.description}
        </div>
    );
};

```
`getStaticProps`から受け取ったデータを展開。

https://nextjs.org/docs/routing/dynamic-routes

https://nextjs.org/docs/basic-features/data-fetching/get-static-paths

https://nextjs.org/docs/basic-features/data-fetching/get-static-props

### 使用したライブラリ
- ハンバーガーメニュー

https://www.npmjs.com/package/react-burger-menu

- アニメーション

https://www.framer.com/motion/

- OGPイメージを取得
Qiita APIから取得したデータにOGPイメージURLが入っていなかったので、記事URLからイメージを取得するため使用。

https://www.npmjs.com/package/ogp-fetcher

- アイコン関係

https://react-icons.github.io/react-icons/

- ページネーション

https://www.npmjs.com/package/react-paginate

- タイプライター（キービジュアルの文字）

https://www.npmjs.com/package/react-simple-typewriter

## パックエンド（Sanity）

https://www.sanity.io/

### Next.jsのプロジェクトにSanityを組み込む
今回は`/src/lib/sanity`ディレクトリ内にプロジェクトを作成しました。

#### インストール
Sanityに登録後、管理画面でプロジェクトを作成し、下記コマンドでCLIをインストール。

```bash
npm install -g @sanity/cli
```

インストールが終われば`sanity`フォルダに移動後、下記コマンドを実行。

```bash
sanity init
```
`/lib/sanity`ディレクトリ内にファイルが追加されます。今回触ったのは`/schemas`フォルダ内と初期設定で作成した`config.js`のみ。
![スクリーン ショット 2022-11-28 に 23.10.56 午後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2638572/60c5f6c0-7fba-f0bf-fa48-d4cb85e24979.png)
次にプロジェクトルートでSanityのツールキットをインストール。

```bash
npm install next-sanity @portabletext/react @sanity/image-url
```

https://github.com/sanity-io/next-sanity

#### 設定
`/lib/sanity`フォルダ内に`config.js`ファイルを作成し、下記のように編集。

```javascript
import { createClient } from "next-sanity";

export const config = {
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
  apiVersion: "2021-10-21",
  useCdn: process.env.NODE_ENV === "production",
  token: process.env.SANITY_API_TOKEN,
};

export const sanityClient = createClient(config);
```
環境変数は`.env`ファイルで管理する。

- `NEXT_PUBLIC_SANITY_DATASET`
Sanity管理画面のDatasetsタブ内に記載。
- `NEXT_PUBLIC_SANITY_PROJECT_ID`
Sanity管理画面上部に記載。
- `SANITY_API_TOKEN`
Sanity管理画面のAPIタブ内左のTokensに記載。

`/lib/sanity`ディレクトリで下記コマンドを実行すると、`localhost:3333`でSanityが立ち上がる。

```bash
saniｔｙ start
```

### スキーマの作成

スキーマはデータベースの構造で、データコンテンツの属性（string, text, image, arrayなど）を定義します。下記コードは製作実績のスキーマで`/schemas`フォルダ内にファイルを作成。

```javascript:/schemas/work.js
export default {
  name: "work",
  title: "Work",
  type: "document",
  fields: [
    {
      name: "title",
      title: "プロジェクト名",
      type: "string",
    },
    {
      name: "sub_title",
      title: "サブタイトル",
      type: "string",
    },
    {
      name: "description",
      title: "概要",
      type: "text",
    },
    {
      name: "url",
      title: "URL or Github",
      type: "string",
    },
    {
      name: "thumbnail",
      title: "サムネイル",
      type: "image",
    },
    {
      name: "technology_stack",
      title: "使用技術",
      type: "array",
      of: [
        {
          name: "technology_stack",
          type: "string",
        },
      ],
    },
    {
      name: "part",
      title: "担当箇所",
      type: "array",
      of: [
        {
          name: "part",
          type: "string",
        },
      ],
    },
  ],
};
```

https://www.sanity.io/docs/schema-types

次に`/shemas/scgema.js`の`concat`メソッドに先ほど作成したスキーマ`work`を渡す。

```javascript
import createSchema from "part:@sanity/base/schema-creator";
import schemaTypes from "all:part:@sanity/base/schema-type";
import work from "./work";

export default createSchema({
  name: "my_site",
  types: schemaTypes.concat([work]),
});
```

これで管理画面に再度アクセスすると、画像のようになっている。反映されない場合は一度サーバーを停止して`sanity start`コマンドを実行する。
![スクリーン ショット 2022-11-28 に 23.44.56 午後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2638572/f938d61f-750b-bf05-5b19-bdd1365d3d31.png)
これで管理画面からデータを追加できます。

### Sanityからデータを取得する
#### メソッドを作成
`/src/utils`フォルダを作成し、この中にSanityからデータを取得するメソッドを作成する。

```typescript:/utils/works/fetchWorks.ts
import { sanityClient } from "lib/sanity/config";
import { groq } from "next-sanity";
import { Work } from "types/work";

/**
 * SanityからWorkデータを全件取得する
 */
export const fetchWorks = async () => {
  const worksQuery = groq`
    * [_type == "work"] {
      ...,
      "thumbnail_url": thumbnail.asset->url
    } | order(_createdAt desc)
  `;
  const works: Work[] = await sanityClient.fetch(worksQuery);
  return works;
};

/**
 * Sanityから特定のWorkデータを1件取得する
 * @param id WorkのID
 */
export const fetchWorkData = async (id: string) => {
  const workQuery = groq`
    * [_type == "work" && _id == "${id}"] {
      ...,
      "thumbnail_url": thumbnail.asset->url
    }
  `;
  try {
    const work: Work = await sanityClient.fetch(workQuery);
    return work;
  } catch (e) {
    console.log(e);
  }
};

```
- `fetchWorks`メソッドは制作実績の全データをsanityから取得するメソッド。冒頭の静的生成するために必要なパスを取得する`getStaticPaths`メソッド内部で呼び出す。
- `fetchWorkData`メソッドは`id`に紐付くデータをsanityから1件取得するメソッド。冒頭の静的生成するための`getStaticProps`メソッド内部で呼び出す。
- groqについて
Sanityのデータベースから値を取得するためのクエリ言語。下記はWorkの全データとサムネイルURLを取得する構文。

```javascript
const worksQuery = groq`
    * [_type == "work"] {
      ...,
      "thumbnail_url": thumbnail.asset->url
    } | order(_createdAt desc)
`;
```
https://www.sanity.io/docs/groq

## デプロイ
### Next.js
VercelにGithubリポジトリをインポートするとビルドが走り、デプロイできた。

https://qiita.com/yamashin0616/items/b5e9dd03ad88b0e289a1

https://yoheiko.com/blog/vercel%E3%81%ABnext-js%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95/

### Santy
`/src/lib/sanity`ディレクトリで下記コマンドを実行するとビルドが走り、`https://<任意の名前>.sanity.studio/desk`にデプロイされる。

```bash
sanity deploy
```

`<任意の名前>`は`sanity deploy`を実行すると入力を求められる。

## Webhook
今のままだとSanity管理画面からデータを更新しても、毎度ビルドしないと最新のデータがサイトに反映されない。なので、常に最新データを反映するため、Vercel、Sanity、QiitaでWebhookを設定し、Sanity管理画面からデータを追加、更新、削除またはQiitaから投稿、更新、削除するとVercelのWebhookエンドポイントにリクエストを送信し、自動的にビルド＆デプロイが実行されるようにした。

:::note warn
QiitaのWebhookはQiita Teamsに加入していないと使用不可のようなので、SanityのWebhookのみの設定になりました。今回はQiita APIから記事取得し一覧表示のみの実装になります。
https://qiita.com/api/webhook/docs
:::

### VercelのWebhook設定

Vercel管理画面の`プロジェクト -> Settings -> Git -> Deploy Hooks`で任意の名前を入力し、Create Hookボタンを押すことでURLが生成されるので、どこかにメモっておく。このURLにリクエストがあるとVercelはビルド＆デプロイを実行。

### SanityのWebhook設定
Sanity管理画面のAPIタブ内左側のWebhooksからマニュアルを見ながら設定。URL欄に先ほどメモしたURLを入力し、POSTメソッドでVercelにリクエストを送信する。

https://www.sanity.io/docs/webhooks

## その他
### 各種計測ツールの導入
特に必要はないが練習も兼ねて、有名な３つの計測ツールを導入した。
#### Google Analytics
アナリティクスでできることは、
- サイトへの流入（検索以外も含む）・セッション・PV等アクセスデータ・CVデータ・サイト内のユーザー行動

#### Google Tag Maneger
色んなツールを一元管理するもの。タグの新規追加を行う（計測ツール導入）際、直接HTMLソースコードを編集する必要がないため、管理画面でサイト内のタグがすべて確認できる。

#### Google Search Console
サーチコンソールでできることは、
- Google検索での表示状況の確認
検索での順位や表示回数、クリック数、クリック率など確認できるインデックス状況が確認できる
- リンク状況の確認
被リンクの数やページURL、被リンク元サイトが確認できる内部リンクの数やページURLが確認できる
- サイトの情報提供
インデックス登録のリクエストやインデックス削除の申請ができるクロールの制御やURLの変更を伝えることができる
- サイトの問題点の把握
表示速度の遅いURLが把握できるエラーやペナルティの有無がわかる

### 構造化データ

検索エンジンがHTMLで記述されたコンテンツを理解しやすいようにタグで整理したもので、今回はGoogleも推奨している、JSON-LDというフォーマットを使用した。

https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data?hl=ja#structured-data-format

JSON-LDのプロパティについてはSchema.orgのサイトを参考に記述。

https://schema.org/WebSite

設置場所は`/pages`フォルダ配下の各ページ内の`<Head>`タグ内に追加した。

```typescript:/pages/example.tsx
const meta = {
    title: "Shiho's Portfolio",
    description: "なんちゃってエンジニアしほっちのポートフォリオサイトです。過去の制作物やQiita記事、身につけたスキルを掲載しています。フロントエンド・サーバーサイド・インフラなど様々なスキルを身につけ、フルスタックエンジニアになることを目指し日々努力中。",
    url: `${process.env.NEXT_PUBLIC_BASE_URL}/`,
};

<Head>
    <title>{meta.title}</title>
    <meta name="description" content={meta.description} />
    <meta property="og:title" content={meta.title} />
    <meta property="og:description" content={meta.description} />
    <meta property="og:url" content={meta.url} />
    <link rel="canonical" href={meta.url} />
    <script
      {...jsonLdScriptProps<BlogPosting>({
        "@context": "https://schema.org",
        "@type": "BlogPosting",
        name: meta.title,
        url: meta.url,
        image: `${process.env.NEXT_PUBLIC_BASE_URL}/share.jpg`,
        description: meta.description,
        headline: meta.title,
        author: "Shiho",
      })}
    />
  </Head>
```

正しく設定されているか確認するにはテストツールがあるのでそちらを使うと良い。

https://search.google.com/test/rich-results?hl=ja

# まとめ

サイトを作ってみてNextの挙動とヘッドレスCMSがなんとなーく理解できたと思います。他にも実装したい項目があるので、引き続きアップデートしていき、さらに理解を深めていきたいと思います。

https://github.com/HikaruShiho/MySite