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

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

## ハンズオンの流れ
1. 開発環境の構築
1. Routing
1. レイアウトコンポーネント
1. ページコンポーネント
1. ブログ一覧を作成
1. 記事ページを作成
1. タイトルをつける

## 開発環境の構築
### インストール
まず、開発環境が以下の要件を満たしていることを確認してください。

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

ターミナルを開いて作業フォルダに移動後、下記のいずれかのコマンドを実行します。
```terminal:terminal
npx create-next-app@latest --experimental-app .

yarn create next-app --experimental-app .

pnpm create next-app --experimental-app .
```

インストール前に２つの設問に答えます。
質問：このプロジェクトでTypeScriptを使いたいですか？
```terminal:terminal
Would you like to use TypeScript with this project? › No / Yes
```
本記事ではTypeScriptを使うため```Yes```を選択しています。

質問：このプロジェクトでESLintを使いたいですか？
```terminal:terminal
Would you like to use ESLint with this project? › No / Yes
```
本記事ではESLintを使うため```Yes```を選択しています。

回答が終わるとインストールが行われます。
### npmスクリプト
package.jsonのscriptsには下記のnpmスクリプトが用意されています。
```json: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の動きを確認する場合は`build`と`start`を組み合わせて使います。

### 動作確認
`dev`を実行して`http://localhost:3000`にアクセスすると、下記の画面が表示されることを確認します。
![スクリーンショット 2022-11-22 11.25.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/0f99e741-fba5-d214-dee0-bdaeee93764f.png)

### ディレクトリ構造
動作確認後のディレクトリ構造は下記の通りです。
![スクリーンショット 2022-11-22 11.37.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/1f1248e1-d4b9-4734-474a-444aff205807.png)
特に重要なディレクトリは以下の３つです。
- .next: `dev`や`build`した際に自動生成されるディレクトリです。静的ファイルやフェッチデータのキャッシュなどが含まれているため、変更は厳禁です。
- app: Routingのルートディレクトリです。詳細は後述しますが、Next.js13では、このディレクトリを中心に開発を行います。
- pages: API Routesのディレクトリです。

## Routing
### `/app`のディレクトリ構造
動作確認後の`/app`ディレクトリ構造は下記の通りです。
![スクリーンショット 2022-11-22 13.10.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/f20d52b3-6336-4a5a-ee9f-3c7a5094f5df.png)

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

### 新しいページを追加する
`/app`ディレクトリに`blog`フォルダと、その中に`page.tsx`作成します。
![スクリーンショット 2022-11-22 13.57.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/5b46d4cf-5278-86cf-40c5-fc6a6f726ba5.png)

`/app/blog/page.tsx`を開き、下記コードを追加します。
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/655a8766-039e-6154-0497-5087fdf400ce.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/9371b840-c3b8-3784-6acd-3886e2d47d6c.png)

`/app/blog/[id]/page.tsx`を開き、下記コードを追加します。
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/749b4c01-1712-9250-f9ea-47bcbf3dedee.png)

`http://localhost:3000/blog/2`にアクセスして、下記の内容が表示されることを確認します。
![スクリーンショット 2022-11-22 14.39.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/26ce8e7d-7d41-85da-f6c9-7b3f7ffb1982.png)

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

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

Next.js13では大括弧で囲ったフォルダを`Dynamic Segments`と呼称しています。
参考文献：[Dynamic Segmentsの公式ドキュメント](https://beta.nextjs.org/docs/routing/defining-routes#dynamic-segments)

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

#### 解説： ページコンポーネントのprops
Dynamic Segmentsのページコンポーネントでは`params`propを受け取っています。

```tsx
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
最後の行は動的パラメータの設定です。（デフォルトは有効）

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

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

![スクリーンショット 2022-11-22 20.23.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/ed97f76b-aa39-8e35-e0b2-8731da5d721e.png)

無効の場合、`generateStaticParams`で設定しなかったセグメンにアクセスしようとすると、404ページが表示されます。
設定を無効にして、`http://localhost:3000/blog/3`にアクセスしようとすると、下記のページが表示されます。
![スクリーンショット 2022-11-22 14.44.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/f3664142-a41a-e478-d851-ede123847a4e.png)
このページはフレームワークが用意したデフォルトの404ページとなります。


## レイアウトコンポーネント(/app/layout.tsx)
`/app/layout.tsx`を開き、下記のコードの差し替えます。
```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`の最後の行に次のスタイルを追加します。

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

また、Blogのリストや記事のスタイルを追加するため、`page.module.css`を下記の内容に書き換えます。
```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 Modules`と`Global Styles`を用いています。
この他にも`Sass`や`Tailwind CSS`、`CSS-in-JS`が使えますが、`CSS-in-JS`は使える場所がクライアントコンポーネントのみに制限されています。
また、CSS-in-JSを用いた[Chakra UI](https://chakra-ui.com/)や[MUI](https://mui.com/)も同様の制限を受けます。

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

両コンポーネントはできる事が排他的となっており、サーバーコンポーネントは、
- データフェッチ可能
- バックエンドリソースへのアクセス可能
- 機密情報（アクセストークン、APIキーなど）をサーバーに保管する
- 大きな依存関係をサーバーに残すことで、クライアントサイドのJavaScriptを削減する

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

となっています。

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

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

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

## ページコンポーネント(/app/page.tsx)
`/app/page.tsx`を開いてコードを修正します。
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/8fbaf457-d028-5c50-7085-2b8b037d7018.png)

### 解説：`<Link/>`
アプリケーション内のページ移動は`<Link/>`を用います。
リンク先は`href`で指定します。
```tsx
<Link href="/blog"></Link>
```
これで`http://localhost:3000/`から`http://localhost:3000/blog`へ移動する事ができるようになります。
（Next.js12以前は内側に`<a>`タグが入れる必要がありましたが、不要になりました。）

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

### 取得関数を作成
`src`フォルダとその中に`lib`フォルダを作成して、`getJsonPlaceholder.ts`ファイルを作成します。
ファイルを開いて、下記のコードを追加します。
```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）を用いて任意のエラーを投げるようにしています。

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

### ページコンポーネントを修正（/app/blog/page.tsx）
`/app/blog/page.tsx`を開いてコードを修正します。

```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`を実行します。
下記のメッセージが表示されたらビルド完了です。
```terminal
λ  (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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/587bfabd-5712-d51a-b529-8df134458417.png)

クリックすると、ブログ一覧が表示されます。
![スクリーンショット 2022-11-22 22.06.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/bf5ab673-3574-7ecc-d533-2fa9d01ebadc.png)


#### 解説：ページコンポーネントがasyncファンクション
サーバーコンポーネントはデータフェッチができるようにasyncファンクションにする事ができます。
```tsx
const page = async () => {
  const posts = await getPosts();
  return ();
};
```

#### 解説：取得データの処理
取得データは配列のため、map関数で展開します。
```tsx
{posts.map(({ id, title }) => (
  <Link key={id} href={`/blog/${id}`} className={styles.card}>
    <div>{title}</div>
  </Link>
))}
```
hrefは`Dynamic Segments`に対応させるため、次のようにしています。
```tsx
href={`/blog/${id}`}
```

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

## 記事ページを作成（/app/blog/[id]/page.tsx）

### 取得関数を作成
`/src/lib/getJsonPlaceholder.ts`の`getPosts`関数の後に次の関数を追加します。
```ts:/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`を書き換えます。

```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/f0c80c75-1ab9-e84d-f6fa-a1b83a7c5373.png)

クリックすると、記事ページに遷移します。
![スクリーンショット 2022-11-22 22.35.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/18885ab3-008f-1af7-c7d1-1e78b9b0342f.png)

### 解説：`generateStaticParams（）`
`[id]`ディレクトリを`Dynamic Segments`に対応させるため、一覧習得で作成したgetPostsを用いてデータ配列を取得して、map関数でオブジェクト配列に変換して返しています。
```tsx
export async function generateStaticParams(): Promise<paramsType[]> {
  const posts = await getPosts();
  return posts.map((post) => ({
    id: post.id.toString(),
  }));
}
```
一覧作成と`Dynamic Segments`で同一のフェッチ関数を実行していますが、Next.js13では１度フェッチした結果を`/.next`でキャッシュしているため、フェッチ回数は１度だけで以降はキャッシュデータを渡してサーバー処理を行います。

### 解説：ページコンポーネント内のデータ取得
ページコンポーネント内でデータの取得を行います。
一覧の時と同様にページコンポーネントはasyncファンクションにしています。
bodyには改行`\n`が含まれているため、`\n`で区切って文字列の配列に変換しています。
```tsx
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`を作成して、下記のコードを追加します。
```tsx:/app/blog/head.tsx
const head = () => {
  return <title>Blog List</title>;
};
export default head;

```

`/app/blog/[id]`ディレクトリに`head.tsx`を作成して、下記のコードを追加します。
```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/b4ce5f48-c471-0432-e2c5-d80e684dada2.png)

Blog Listから任意の記事に移動すると、タイトルがブログのタイトルに変わります。
![スクリーンショット 2022-11-23 22.49.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890567/91629f91-1803-8867-aa65-c085c1bb7242.png)

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

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

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

https://github.com/GrowUp-Haruno/nextjs13-learning-blog-qiita

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