6
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ハンズオン!Next.js 13+{JSON} Placeholdeで作る簡易ブログ(SSG編)

Posted at

はじめに

Next.js13がリリースされてもうすぐ1ヶ月が経ちましたが、まだ公式チュートリアルが最新版に対応したものが出ていないため、公式ドキュメントを読み進めながら{JSON} Placeholdeと連携したSSG対応の簡易ブログを作成しました。
本記事は、ハンズオン形式でこのブログの作成方法をまとめています。

ハンズオンの流れ

  1. 開発環境の構築
  2. Routing
  3. レイアウトコンポーネント
  4. ページコンポーネント
  5. ブログ一覧を作成
  6. 記事ページを作成
  7. タイトルをつける

開発環境の構築

インストール

まず、開発環境が以下の要件を満たしていることを確認してください。

  • Node.js 16.8以降(node -vで確認)
  • MacOS、Windows(WSLを含む)、Linuxに対応

ターミナルを開いて作業フォルダに移動後、下記のいずれかのコマンドを実行します。

terminal
npx create-next-app@latest --experimental-app .

yarn create next-app --experimental-app .

pnpm create next-app --experimental-app .

インストール前に2つの設問に答えます。
質問:このプロジェクトでTypeScriptを使いたいですか?

terminal
Would you like to use TypeScript with this project? › No / Yes

本記事ではTypeScriptを使うためYesを選択しています。

質問:このプロジェクトでESLintを使いたいですか?

terminal
Would you like to use ESLint with this project? › No / Yes

本記事ではESLintを使うためYesを選択しています。

回答が終わるとインストールが行われます。

npmスクリプト

package.jsonのscriptsには下記のnpmスクリプトが用意されています。

package.json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
}
  • dev: Next.jsを開発モードで開始します。
  • build: 本番用のアプリケーションをビルドします。
  • start: Next.jsのプロダクションサーバーを起動します。
  • lint: Next.jsの組み込みESLintを設定します。

レイアウトの確認などはdev、SSG/SSRの動きを確認する場合はbuildstartを組み合わせて使います。

動作確認

devを実行してhttp://localhost:3000にアクセスすると、下記の画面が表示されることを確認します。
スクリーンショット 2022-11-22 11.25.32.png

ディレクトリ構造

動作確認後のディレクトリ構造は下記の通りです。
スクリーンショット 2022-11-22 11.37.46.png
特に重要なディレクトリは以下の3つです。

  • .next: devbuildした際に自動生成されるディレクトリです。静的ファイルやフェッチデータのキャッシュなどが含まれているため、変更は厳禁です。
  • app: Routingのルートディレクトリです。詳細は後述しますが、Next.js13では、このディレクトリを中心に開発を行います。
  • pages: API Routesのディレクトリです。

Routing

/appのディレクトリ構造

動作確認後の/appディレクトリ構造は下記の通りです。
スクリーンショット 2022-11-22 13.10.14.png

  • global.css: アプリ全体のスタイルシート
  • page.module.css: ページ単位のスタイルシート
  • head.tsx: 特殊ファイル。共通のタグを設定することができます。
  • layout.tsx: 特殊ファイル。共通のレイアウトを設定することができます。
  • page.tsx: 特殊ファイル。コンテンツを設定することができます。

新しいページを追加する

/appディレクトリにblogフォルダと、その中にpage.tsx作成します。
スクリーンショット 2022-11-22 13.57.17.png

/app/blog/page.tsxを開き、下記コードを追加します。

/app/blog/page.tsx
const page = () => {
  return <div>blog</div>;
};
export default page;

http://localhost:3000/blogにアクセスして、下記の内容が表示されることを確認します。

スクリーンショット 2022-11-22 14.00.45.png

解説

/appとその中に入れたフォルダー内にpage.tsxが存在すれば、下記のページにアクセスすることができます。

  • /app/page.tsx => http://localhost:3000/
  • /app/blog/page.tsx => http://localhost:3000/blog

記事ページを作成する

/app/blogディレクトリに[id]ディレクトリと、その中にpage.tsx作成します。
スクリーンショット 2022-11-22 14.24.27.png

/app/blog/[id]/page.tsxを開き、下記コードを追加します。

/app/blog/[id]/page.tsx
type paramsType = {
  id: string;
};

export async function generateStaticParams(): Promise<paramsType[]> {
  return [{ id: '1' }, { id: '2' }];
}

const page = ({ params }: { params: paramsType }) => {
  return <div>blog記事:その{params.id}</div>;
};
export default page;

export const dynamicParams = false;

http://localhost:3000/blog/1にアクセスして、下記の内容が表示されることを確認します。
スクリーンショット 2022-11-22 14.38.38.png

http://localhost:3000/blog/2にアクセスして、下記の内容が表示されることを確認します。
スクリーンショット 2022-11-22 14.39.24.png

解説: generateStaticParams関数

フォルダ名を大括弧で囲い、page.tsxでgenerateStaticParams関数の戻り値を以下のように設定すると、[id]12として扱うことができるようになります。

return [{ id: '1' }, { id: '2' }];

Next.js13では大括弧で囲ったフォルダをDynamic Segmentsと呼称しています。
参考文献:Dynamic Segmentsの公式ドキュメント

フォルダ名とgenerateStaticParams関数の戻り値のオブジェクト名が同じであれば良いので、例えばフォルダ名を[slug]にしたい場合、戻り値は次のようにします。

return [{ slug: '1' }, { slug: '2' }];

解説: ページコンポーネントのprops

Dynamic Segmentsのページコンポーネントではparamspropを受け取っています。

const page = ({ params }: { params: paramsType }) => {
  return <div>blog記事:その{params.id}</div>;
};
export default page;

http://localhost:3000/blog/1にアクセスした場合、paramsには{id:1}入ります。
http://localhost:3000/blog/2にアクセスした場合、paramsには{id:2}入ります。

解説: dynamicParams

最後の行は動的パラメータの設定です。(デフォルトは有効)

export const dynamicParams = false;

有効の場合、generateStaticParams関数で設定したセグメント以外でもアクセスが可能となります。

設定を有効にして、http://localhost:3000/blog/3にアクセスすると、params{id:3}を渡してSSRされた結果が表示されます。

スクリーンショット 2022-11-22 20.23.48.png

無効の場合、generateStaticParamsで設定しなかったセグメンにアクセスしようとすると、404ページが表示されます。
設定を無効にして、http://localhost:3000/blog/3にアクセスしようとすると、下記のページが表示されます。
スクリーンショット 2022-11-22 14.44.44.png
このページはフレームワークが用意したデフォルトの404ページとなります。

レイアウトコンポーネント(/app/layout.tsx)

/app/layout.tsxを開き、下記のコードの差し替えます。

/app/layout.tsx
import Image from 'next/image';
import './globals.css';
import styles from './page.module.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="jp">
      <head />
      <body>
        <div className={styles.container}>
          {children}
          <footer className={styles.footer}>
            <a
              href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
              target="_blank"
              rel="noopener noreferrer"
            >
              Powered by{' '}
              <span className={styles.logo}>
                <Image
                  src="/vercel.svg"
                  alt="Vercel Logo"
                  width={72}
                  height={16}
                />
              </span>
            </a>
          </footer>
        </div>
      </body>
    </html>
  );
}

{children}にpageコンポーネント(/app/page.tsx/app/blog/page.tsx/app/blog/[id]/page.tsx)の内容が入ります。
ただし、<div data-nextjs-scroll-focus-boundary></div>でラップされるため、レイアウトがうまく反映できない問題が発生します。
これを回避するため、global.cssの最後の行に次のスタイルを追加します。

/app/global.css
[data-nextjs-scroll-focus-boundary] {
  display: contents;
}

また、Blogのリストや記事のスタイルを追加するため、page.module.cssを下記の内容に書き換えます。

/app/page.module.css
.container {
  padding: 0 2rem;
  display: flex;
  min-height: 100vh;
  flex-direction: column;
}

.main {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  row-gap: 64px;
}

.footer {
  display: flex;
  padding: 2rem 0;
  border-top: 1px solid #eaeaea;
  justify-content: center;
  align-items: center;
}

.footer a {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-grow: 1;
}

.title {
  margin: 0;
  line-height: 1.15;
  font-size: 4rem;
  font-style: normal;
  font-weight: 800;
  letter-spacing: -0.025em;
}

.title a {
  text-decoration: none;
  color: #0070f3;
}

.title a:hover,
.title a:focus,
.title a:active {
  text-decoration: underline;
}

.title,
.description {
  text-align: center;
}

.description {
  margin: 4rem 0;
  line-height: 1.5;
  font-size: 1.5rem;
}

.article {
  margin: 4rem 0;
  line-height: 1.5;
  font-size: 1.5rem;
}

.code {
  background: #fafafa;
  border-radius: 5px;
  padding: 0.75rem;
  font-size: 1.1rem;
  font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
    Bitstream Vera Sans Mono, Courier New, monospace;
}

.grid {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  max-width: 1200px;
}

.card {
  margin: 1rem;
  padding: 1.5rem;
  text-align: left;
  color: inherit;
  text-decoration: none;
  border: 1px solid #eaeaea;
  border-radius: 10px;
  transition: color 0.15s ease, border-color 0.15s ease;
  width: 300px;
}

.card:hover,
.card:focus,
.card:active {
  color: #0070f3;
  border-color: #0070f3;
}

.card h2 {
  margin: 0 0 1rem 0;
  font-size: 1.5rem;
}

.card p {
  margin: 0;
  font-size: 1.25rem;
  line-height: 1.5;
}

.logo {
  height: 1em;
  margin-left: 0.5rem;
}

@media (max-width: 600px) {
  .grid {
    width: 100%;
    flex-direction: column;
  }
}

@media (prefers-color-scheme: dark) {
  .title {
    background: linear-gradient(180deg, #ffffff 0%, #aaaaaa 100%);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    text-fill-color: transparent;
  }
  .title a {
    background: linear-gradient(180deg, #0070f3 0%, #0153af 100%);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    text-fill-color: transparent;
  }
  .card,
  .footer {
    border-color: #222;
  }
  .code {
    background: #111;
  }
  .logo img {
    filter: invert(1);
  }
}

スタイルについて

本記事のスタイルはCSS ModulesGlobal Stylesを用いています。
この他にもSassTailwind CSSCSS-in-JSが使えますが、CSS-in-JSは使える場所がクライアントコンポーネントのみに制限されています。
また、CSS-in-JSを用いたChakra UIMUIも同様の制限を受けます。

サーバーコンポーネントとクライアントコンポーネントについて

Next.js13のアプリはサーバーコンポーネントクライアントコンポーネントを組み合わせて開発を進めていきます。
/appディレクトリ内のコンポーネントは、設定変更しない限り全てサーバーコンポーネントとして動作します。

両コンポーネントはできる事が排他的となっており、サーバーコンポーネントは、

  • データフェッチ可能
  • バックエンドリソースへのアクセス可能
  • 機密情報(アクセストークン、APIキーなど)をサーバーに保管する
  • 大きな依存関係をサーバーに残すことで、クライアントサイドのJavaScriptを削減する

クライアントコンポーネントは、

  • インタラクティブ機能とイベントリスナー(onClick()、onChange()など)を使用可能
  • ステートとライフサイクルエフェクト (useState(), useReducer(), useEffect(), etc.)が使用可能
  • ブラウザ専用のAPIを使用可能
  • ステート、エフェクト、ブラウザ専用APIに依存するカスタムフックの使用可能
  • React Class componentsを使用可能

となっています。

クライアントコンポーネントを使うには、ファイルの1行目に下記のコメントを追加することで有効になります。

'use client';

サーバーコンポーネントはステートを持つ事ができませんが、クライアントコンポーネントをインポートして配置する事ができます。

本記事で作成するBlogは、errorコンポーネント(error.tsx)を除き全てサーバーコンポーネントとなります。

ページコンポーネント(/app/page.tsx)

/app/page.tsxを開いてコードを修正します。

/app/page.tsx(サーバーコンポーネント)
import Link from 'next/link';
import styles from './page.module.css';

export default function Home() {
  return (
    <main className={styles.main}>
      <h1 className={styles.title}>
        Welcome to <a href="https://nextjs.org">Next.js 13!</a>
      </h1>

      <div className={styles.grid}>
        <Link href="/blog" className={styles.card}>
          <h2>Blog(SSG版)</h2>
          <p>ビルド時にSSGされたBlogはこちら</p>
        </Link>
      </div>
    </main>
  );
}

http://localhost:3000/にアクセスして、下記の内容が表示されることを確認します。

スクリーンショット 2022-11-22 21.06.05.png

解説:<Link/>

アプリケーション内のページ移動は<Link/>を用います。
リンク先はhrefで指定します。

<Link href="/blog"></Link>

これでhttp://localhost:3000/からhttp://localhost:3000/blogへ移動する事ができるようになります。
(Next.js12以前は内側に<a>タグが入れる必要がありましたが、不要になりました。)

ブログ一覧を作成(/app/blog/page.tsx)

/app/blog/page.tsxでは、{JSON} Placeholdepostsエンドポイントからブログ一覧を取得して、ブログ一覧をカード形式で表示させます。

取得関数を作成

srcフォルダとその中にlibフォルダを作成して、getJsonPlaceholder.tsファイルを作成します。
ファイルを開いて、下記のコードを追加します。

/src/lib/getJsonPlaceholder.ts
export type postType = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

type postsType = postType[];

export const getPosts = async () => {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts');
  if (!res.ok) throw new Error('getPostsで異常が発生しました。');
  return res.json() as Promise<postsType>;
};

通信が完了すると、postsType型のデータを取得する事ができます。

fetch関数は通信を失敗するなどしてレスポンスを受け取れなくてもErrorを投げないため、res.okのフラグ(レンスポンスを受け取れなかった場合false)を用いて任意のエラーを投げるようにしています。

if (!res.ok) throw new Error('getPostsで異常が発生しました。');

ページコンポーネントを修正(/app/blog/page.tsx)

/app/blog/page.tsxを開いてコードを修正します。

/app/blog/page.tsx(サーバーコンポーネント)
import Link from 'next/link';
import { getPosts } from '../../src/lib/getJsonPlaceholder';
import styles from '../page.module.css';

const page = async () => {
  const posts = await getPosts();
  return (
    <main className={styles.main}>
      <h1 className={styles.title}>Blog List</h1>
      <div className={styles.grid}>
        {posts.map(({ id, title }) => (
          <Link key={id} href={`/blog/${id}`} className={styles.card}>
            <div>{title}</div>
          </Link>
        ))}
      </div>
    </main>
  );
};
export default page;

ここでSSGの動きを確認するため一度devサーバを止めて、buildを実行します。
下記のメッセージが表示されたらビルド完了です。

λ  (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)

次にstartを実行して、http://localhost:3000/にアクセスして、下記のカードをクリックします。
スクリーンショット 2022-11-22 22.04.39.png

クリックすると、ブログ一覧が表示されます。
スクリーンショット 2022-11-22 22.06.56.png

解説:ページコンポーネントがasyncファンクション

サーバーコンポーネントはデータフェッチができるようにasyncファンクションにする事ができます。

const page = async () => {
  const posts = await getPosts();
  return ();
};

解説:取得データの処理

取得データは配列のため、map関数で展開します。

{posts.map(({ id, title }) => (
  <Link key={id} href={`/blog/${id}`} className={styles.card}>
    <div>{title}</div>
  </Link>
))}

hrefはDynamic Segmentsに対応させるため、次のようにしています。

href={`/blog/${id}`}

データの取得はビルド時にサーバー上で行った後、静的ファイルを生成します。
こうすることで、クライアント側でフェッチ関数をダウンロードして実行することなく一覧をすばやく表示する事ができるようになります。

記事ページを作成(/app/blog/[id]/page.tsx)

取得関数を作成

/src/lib/getJsonPlaceholder.tsgetPosts関数の後に次の関数を追加します。

/src/lib/getJsonPlaceholder.ts
export const getPost = async (id: string) => {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  if (!res.ok) throw new Error('getPostで異常が発生しました。');
  return res.json() as Promise<postType>;
};

引数に文字列型のidを渡すことで、個別のデータを取得する事ができます。

次に/app/blog/[id]/page.tsxを書き換えます。

/app/blog/[id]/page.tsx
import { getPost, getPosts } from '../../../src/lib/getJsonPlaceholder';
import styles from '../../page.module.css';

type paramsType = {
  id: string;
};

export async function generateStaticParams(): Promise<paramsType[]> {
  const posts = await getPosts();
  return posts.map((post) => ({
    id: post.id.toString(),
  }));
}

const page = async ({ params }: { params: paramsType }) => {
  const { title, body } = await getPost(params.id);
  const bodys = body.split('\n');

  return (
    <main className={styles.main}>
      <h1 className={styles.title}>{title}</h1>
      <div className={styles.article}>
        {bodys.map((body, i) => (
          <p key={i}>{body}</p>
        ))}
      </div>
    </main>
  );
};
export default page;

export const dynamicParams = false;

Blog一覧の時と同様にサーバを止めて、buildを実行します。
ビルドが完了したら、startを実行して、http://localhost:3000/blogにアクセスして、一番上のカードをクリックします。
スクリーンショット 2022-11-22 22.34.30.png

クリックすると、記事ページに遷移します。
スクリーンショット 2022-11-22 22.35.59.png

解説:generateStaticParams()

[id]ディレクトリをDynamic Segmentsに対応させるため、一覧習得で作成したgetPostsを用いてデータ配列を取得して、map関数でオブジェクト配列に変換して返しています。

export async function generateStaticParams(): Promise<paramsType[]> {
  const posts = await getPosts();
  return posts.map((post) => ({
    id: post.id.toString(),
  }));
}

一覧作成とDynamic Segmentsで同一のフェッチ関数を実行していますが、Next.js13では1度フェッチした結果を/.nextでキャッシュしているため、フェッチ回数は1度だけで以降はキャッシュデータを渡してサーバー処理を行います。

解説:ページコンポーネント内のデータ取得

ページコンポーネント内でデータの取得を行います。
一覧の時と同様にページコンポーネントはasyncファンクションにしています。
bodyには改行\nが含まれているため、\nで区切って文字列の配列に変換しています。

const page = async ({ params }: { params: paramsType }) => {
  const { title, body } = await getPost(params.id);
  const bodys = body.split('\n');

  return ...
}

タイトルをつける(/app/blog/head.tsx, /app/blog/[id]/head.tsx)

/app/blogディレクトリにhead.tsxを作成して、下記のコードを追加します。

/app/blog/head.tsx
const head = () => {
  return <title>Blog List</title>;
};
export default head;

/app/blog/[id]ディレクトリにhead.tsxを作成して、下記のコードを追加します。

/app/blog/[id]/head.tsx
import { getPost } from '../../../src/lib/getJsonPlaceholder';

type paramsType = {
  id: string;
};

const head = async ({ params }: { params: paramsType }) => {
  const { title } = await getPost(params.id);
  return <title>{title}</title>;
};
export default head;

http://localhost:3000/blogにアクセスすると、タイトルがBlog Listに変わります。
スクリーンショット 2022-11-23 22.48.43.png

Blog Listから任意の記事に移動すると、タイトルがブログのタイトルに変わります。
スクリーンショット 2022-11-23 22.49.24.png

解説:head.tsx

各ディレクトリに配置したヘッダファイルにはそれぞれページタイトルを設定しています。
/app/head.tsxでもページタイトルを設定していますが、この場合は各ディレクトリに配置したヘッダファイルが優先されます。

/app/blog/[id]/head.tsxでは/app/blog/[id]/page.tsxと同様にparamsを受け取る事ができます。
また、フェッチ(キャッシュ取得)も可能ですので、取得データを用いてページタイトルに設定しています。

最後に

作成したプロジェクトをGitHubに上げています。

今回はSSGで動くように作成しました。
フェッチの設定を変えることで簡単にSSR化できるため、今後別記事でまとめる予定です。

6
8
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
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?