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(スタイリング)
アーキテクチャ
コードを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エラー |
ベースレイアウト
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では簡単にできる。
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;
これが全ページ共通で、ページごとにタイトルやディスクリプション、その他情報を追加する場合は、
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から取得しレンダリング行う。
コード
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
のみ。
次にプロジェクトルートで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
フォルダ内にファイルを作成。
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.js
のconcat
メソッドに先ほど作成したスキーマ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
コマンドを実行する。
これで管理画面からデータを追加できます。
Sanityからデータを取得する
メソッドを作成
/src/utils
フォルダを作成し、この中にSanityからデータを取得するメソッドを作成する。
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>
タグ内に追加した。
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がなんとなーく理解できたと思います。他にも実装したい項目があるので、引き続きアップデートしていき、さらに理解を深めていきたいと思います。