6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Jamstackなサイト制作(Next.js×Sanity)

Last updated at Posted at 2022-12-01

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

Jamstackとは

Web開発アーキテクチャのひとつ。

  • JavaScript
  • API
  • Markup(HTML)

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

Jamstackのメリット

高パフォーマンス

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

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
コードをGithubにPushするとVercelが自動でビルドとデプロイを行なってくれる。
また、VercelのDeploy Hookを利用し、QiitaとSanityにコンテンツの追加や更新、削除があった時にWebhookを使い、VercelのエンドポイントにPOSTリクエストを送信し、自動でビルドとデプロイを行う。
自分が書いたQiitaの記事は、Qiita APIを使用しデータを取得した。

実装

フロントエンド(Next.js)

ページ構成

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

ベースレイアウト

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>で囲った要素が渡ってきます。

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

export default Example;

こうすると・・・

const Layout = ({ children }: Props) => {
  return (
    <>
      <Meta />
      <Header />
      <main>テストページです</main>
      <Footer />
    </>
  );
};

こうなる。

ページごとにメタタグを出し分け

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

/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;

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

/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に関しては下記参照。

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

コード

/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,
    },
  };
};

解説

const works = await fetchWorks();

fetchWorksメソッドで製作実績を全件取得し、変数に格納。
fetchWorksメソッドはutils/work/fetchWork.tsxに作成したSanityからデータを取得するためのメソッド。

const paths = works?.map((work) => {
    return { params: { id: work._id } };
  });
return { paths, fallback: false };

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

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からデータを取得するためのメソッド。

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

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

使用したライブラリ

  • ハンバーガーメニュー

  • アニメーション

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

  • アイコン関係

  • ページネーション

  • タイプライター(キービジュアルの文字)

パックエンド(Sanity)

Next.jsのプロジェクトにSanityを組み込む

今回は/src/lib/sanityディレクトリ内にプロジェクトを作成しました。

インストール

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

npm install -g @sanity/cli

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

sanity init

/lib/sanityディレクトリ内にファイルが追加されます。今回触ったのは/schemasフォルダ内と初期設定で作成したconfig.jsのみ。
スクリーン ショット 2022-11-28 に 23.10.56 午後.png
次にプロジェクトルートでSanityのツールキットをインストール。

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

設定

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

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が立ち上がる。

sanity start

スキーマの作成

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

/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",
        },
      ],
    },
  ],
};

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

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
これで管理画面からデータを追加できます。

Sanityからデータを取得する

メソッドを作成

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

/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を取得する構文。
const worksQuery = groq`
    * [_type == "work"] {
      ...,
      "thumbnail_url": thumbnail.asset->url
    } | order(_createdAt desc)
`;

デプロイ

Next.js

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

Santy

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

sanity deploy

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

Webhook

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

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にリクエストを送信する。

その他

各種計測ツールの導入

特に必要はないが練習も兼ねて、有名な3つの計測ツールを導入した。

Google Analytics

アナリティクスでできることは、

  • サイトへの流入(検索以外も含む)・セッション・PV等アクセスデータ・CVデータ・サイト内のユーザー行動

Google Tag Maneger

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

Google Search Console

サーチコンソールでできることは、

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

構造化データ

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

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

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

/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>

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

まとめ

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

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?