ISR でプチ魚拓を作ってみる

この記事は、[Next.js Advent Calendar 2020](https://qiita.com/advent-calendar/2020/nextjs) の20日目の記事です。

今回は、 Next.js の今年のアップデートのうちでも話題になった ISR (Incremental Static Regeneration) を使って、プチ魚拓アプリをつくりたいと思います。


できたアプリ

[プチ魚拓](https://advent-calendar-2020-demo-app.vercel.app/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110426/8c637b40-1528-6ff6-fd18-d13ef7ca72dc.png)



## アプリ設計・実装

今回のアプリで、魚拓にする「魚」はリアルタイム性があるものほうが分かりやすいので、この時期、頻繁に更新されてリアルタイム性のある、Qiita の投稿記事の一覧を「魚」にしたいと思います。

### API

今回使用する Qiita の API は `/api/v2/items` です。

ドキュメントはこちら → [https://qiita.com/api/v2/docs#get-apiv2items](https://qiita.com/api/v2/docs#get-apiv2items)




### コード

pages以下には、 index ページ と API Routes として api/posts を用意しています。

```sh
.
├── api
│   └── posts.ts
├── index.tsx
```

今回、大事なところは `index.tsx` に書いている。以下の部分です。

```typescript
export const getStaticProps: GetStaticProps = async () => {
  const now = utcToZonedTime(new Date(), 'Asia/Tokyo');
  const nowDateTimeString = format(now, 'yyyy/MM/dd HH:mm:ss', {
    timeZone: 'Asia/Tokyo',
  });

  const posts = await getData();

  return {
    props: { nowDateTimeString, posts },
    revalidate: 3600,
  };
};
```

ページ自体の更新日時等を叩き込むために、現在日時を取得したりもしていますが、 `getData()` をしているところが Qiita API 投稿一覧を取得しに行っている処理です。このコードでは、返却値の `revalidate` が `3600` に設定されているので、少なくとも **1時間** はページが再生成されないことになります。

**この挙動がプチ魚拓っぽいですね。というところがこの記事の肝です。**

コード全体も載せておきますが、後述のおまけ用のコードも入っているので初見分かりにくいです。（すみません）

<details>
<summary>api/posts.ts</summary>
<div>

```typescript
import { NextApiRequest, NextApiResponse } from 'next';
import { format, parseISO } from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';

type Post = {
  title: string;
  createdAt: string;
  url: string;
};

export const getData = async () => {
  const res = await fetch('https://qiita.com/api/v2/items');

  if (!res.ok) {
    throw new Error("can't fetch");
  }
  const data = await res.json();

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const posts = data.map((post: any) => {
    const dt = parseISO(post.created_at);
    const localDt = utcToZonedTime(dt, 'Asia/Tokyo');

    return {
      title: post.title,
      createdAt: format(localDt, 'yyyy/MM/dd hh:mm:ss'),
      url: post.url,
    };
  });

  return posts;
};

const handler = async (req: NextApiRequest, res: NextApiResponse<Post>) => {
  const posts = await getData();
  res.status(200).json(posts);
};

export default handler;
```

</div>
</details>

<details>
<summary>index.tsx</summary>
<div>

```typescript
import React, { VFC } from 'react';
import { NextPage, GetStaticProps } from 'next';
import { useRouter } from 'next/router';
import tw, { styled } from 'twin.macro';
import { format } from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
import { useForm, UseFormMethods, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { getData } from 'pages/api/posts';
import * as z from 'zod';

type IndexPageProps = {
  className?: string;
  nowDateTimeString: string;
  posts: { title: string; createdAt: string; url: string }[];
};

type Props = {
  register: UseFormMethods['register'];
  handleSubmit: UseFormMethods['handleSubmit'];
  errors: UseFormMethods['errors'];
  onValid: SubmitHandler<FormValues>;
} & IndexPageProps;

const Component: VFC<Props> = (props) => {
  const {
    className,
    register,
    handleSubmit,
    errors,
    onValid,
    nowDateTimeString,
    posts,
  } = props;

  return (
    <div className={className}>
      <div className="qiita">
        <div className="title">
          Qiita Posts
          <div className="updatedTime">{nowDateTimeString} 更新</div>
        </div>
        <ul className="posts">
          {posts.map((post) => (
            <li key={post.createdAt}>
              <div className="createdAt">{post.createdAt}</div>
              <div className="postTitle">
                <a href={post.url}>{post.title}</a>
              </div>
            </li>
          ))}
        </ul>
      </div>
      <div className="calendar">
        <form className="form" onSubmit={handleSubmit(onValid)}>
          <input type="date" name="date" ref={register} />
          {errors.date && (
            <div className="errorMessage">{errors.date.message}</div>
          )}
          <button className="submitButton" type="submit">
            検索
          </button>
        </form>
      </div>
    </div>
  );
};

const StyledComponent = styled(Component)`
  & > .qiita {
    ${tw`mb-20`}

    & > .title {
      ${tw`flex items-center justify-center font-bold`}

      & > .updatedTime {
        ${tw`ml-2`}
      }
    }

    & > .posts {
      & > li {
        ${tw`list-none mb-2 flex items-center`}

        & > .createdAt {
          ${tw`mr-4 flex-none`}
        }
      }
    }
  }

  & > .calendar {
    & > .form {
      ${tw`flex flex-col items-center`}

      .errorMessage {
        ${tw`text-red-500`}
      }

      & > .submitButton {
        ${tw`mt-6 mb-10`}
      }
    }
  }
`;

const schema = z.object({
  date: z.string().length(10, { message: 'Should choose a date' }),
});

type FormValues = z.infer<typeof schema>;

const IndexPage: NextPage<IndexPageProps> = (props) => {
  const { push } = useRouter();

  const { register, handleSubmit, errors } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  const onValid = async (data: FormValues) => {
    const { date } = data;
    // console.log(data);
    push(`/date/${date}`);
  };

  return (
    <StyledComponent
      {...props}
      {...{ register, handleSubmit, errors, onValid }}
    />
  );
};

export const getStaticProps: GetStaticProps = async () => {
  const now = utcToZonedTime(new Date(), 'Asia/Tokyo');
  const nowDateTimeString = format(now, 'yyyy/MM/dd HH:mm:ss');

  const posts = await getData();

  return {
    props: { nowDateTimeString, posts },
    revalidate: 3600,
  };
};

export default IndexPage;
```
</div>
</details>

他、細かいコードには、こちらをご参照ください。

[https://github.com/uitspitss/advent-calendar-2020-demo-app](https://github.com/uitspitss/advent-calendar-2020-demo-app)


## 動作確認

- デプロイ後のアクセスでは、 revalidate がいくつに設定されていてもページ生成が走ります。
- 生成後、１時間はページの再生成がされないので、 「Qiita Posts」の後の更新日時が更新されず、新しい記事も入ってこないはず…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110426/ab7f12eb-dd9c-80bf-7e0a-3e2574ea01f7.png)

## おまけ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110426/42ca021d-71a0-5a86-461a-4378fd6f20f4.png)

Qiita 記事一覧の下に謎に置かれているカレンダーフィールドのフォームは、元々作ろうとしていた「◯日前はいつ？」みたいなアプリを作ろうとしていた名残です。今回、 Qiita の記事一覧を「魚」に使いましたが、元々は Wikipedia の日単位のページの一部を拾ってきて、叩き込もうとしていました。ただ、APIを叩いて情報を見ているうちに、拾ってきたデータ自体にリアルタイム性がほぼなかったので、 Qiita の記事一覧に切り替えました。

Qiita 記事一覧のページが１時間の再生成間隔で、１時間後に確認してみるということ難しいかなと思いましたので、 `pages/date/[date].tsx` で Dynamic Routes のページを用意しています。こちらは `revalidate: 100` なので、100秒後には再生成が可能になります。
適当な日付を選ぶと、読者のあなたがアクセスしたページになるはずなので、100秒後に再生成可能になっていて、再生成されるのか確認できるかと思います。

適当な日付を選んで「検索」ボタン押下

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110426/5e648b7d-3601-f273-3688-45049b7cea9a.png)

↓

ページが生成された日が叩き込まれる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110426/bb4c8960-6e2f-fa0f-e03d-f94dfa4daae6.png)

↓ 100秒後以降

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/110426/3bd9752d-00c9-f48a-a70f-47cb649133a2.png)


## 参考にした記事等

- Next.js のブログ記事
  - https://nextjs.org/blog/next-9-4#incremental-static-regeneration-beta
- 「Next.js の Incremental Static Regeneration を理解する」 by Ria さん
  - https://zenn.dev/ria/articles/b709ae94e919c76f814a
  - Dynamic Routes での方法も言及されていたので詰まりませんでした。
- 「ISRでブログを作ってみて気づいたこと・ハマったこと」 by saddnessOjisan さん
  - https://blog.ojisan.io/til-ojisan-io
  - API Routes との併用で注意点などに言及されていました。
- 「Next.jsをサーバーレスでやっていくためのServerless Next.js Component」 by Keisuke69 さん
  - https://www.keisuke69.net/entry/2020/11/27/163208
  - 生成されたページに日時を叩き込むアイデアは、この記事のライブストリーミングを見ていて使えそうと思いました。