この記事は、Next.js Advent Calendar 2020 の20日目の記事です。
今回は、 Next.js の今年のアップデートのうちでも話題になった ISR (Incremental Static Regeneration) を使って、プチ魚拓アプリをつくりたいと思います。
できたアプリ
アプリ設計・実装
今回のアプリで、魚拓にする「魚」はリアルタイム性があるものほうが分かりやすいので、この時期、頻繁に更新されてリアルタイム性のある、Qiita の投稿記事の一覧を「魚」にしたいと思います。
API
今回使用する Qiita の API は /api/v2/items
です。
ドキュメントはこちら → https://qiita.com/api/v2/docs#get-apiv2items
コード
pages以下には、 index ページ と API Routes として api/posts を用意しています。
.
├── api
│ └── posts.ts
├── index.tsx
今回、大事なところは index.tsx
に書いている。以下の部分です。
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時間 はページが再生成されないことになります。
この挙動がプチ魚拓っぽいですね。というところがこの記事の肝です。
コード全体も載せておきますが、後述のおまけ用のコードも入っているので初見分かりにくいです。(すみません)
api/posts.ts
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;
index.tsx
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;
他、細かいコードには、こちらをご参照ください。
https://github.com/uitspitss/advent-calendar-2020-demo-app
動作確認
- デプロイ後のアクセスでは、 revalidate がいくつに設定されていてもページ生成が走ります。
- 生成後、1時間はページの再生成がされないので、 「Qiita Posts」の後の更新日時が更新されず、新しい記事も入ってこないはず…
おまけ
Qiita 記事一覧の下に謎に置かれているカレンダーフィールドのフォームは、元々作ろうとしていた「◯日前はいつ?」みたいなアプリを作ろうとしていた名残です。今回、 Qiita の記事一覧を「魚」に使いましたが、元々は Wikipedia の日単位のページの一部を拾ってきて、叩き込もうとしていました。ただ、APIを叩いて情報を見ているうちに、拾ってきたデータ自体にリアルタイム性がほぼなかったので、 Qiita の記事一覧に切り替えました。
Qiita 記事一覧のページが1時間の再生成間隔で、1時間後に確認してみるということ難しいかなと思いましたので、 pages/date/[date].tsx
で Dynamic Routes のページを用意しています。こちらは revalidate: 100
なので、100秒後には再生成が可能になります。
適当な日付を選ぶと、読者のあなたがアクセスしたページになるはずなので、100秒後に再生成可能になっていて、再生成されるのか確認できるかと思います。
適当な日付を選んで「検索」ボタン押下
↓
ページが生成された日が叩き込まれる
↓ 100秒後以降
参考にした記事等
- Next.js のブログ記事
- 「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
- 生成されたページに日時を叩き込むアイデアは、この記事のライブストリーミングを見ていて使えそうと思いました。