App Router で Hacker News Reader 作ってみた
はじめに
今回 Next.js の App Router の勉強がてら Hacker News の Reader アプリを作ってみたので、備忘録を兼ねて解説記事を書いてみました。注意点として、私は App Router に関しては公式チュートリアルをなぞったくらいの知識なので参考程度に見ていただけると幸いです。また間違ってる箇所や改善点などあればコメントなどいただけると大変助かります!
つくったアプリ👇
コードはこちら👇
使用技術
今回つくったアプリは以下のような技術で構成されています。アプリの内容としては Hacker News の API を叩いて結果を表示するだけのかなりシンプルなものなので、Next.js を使ったアプリとしては最小構成に近い形になっているかと思います。
また、デプロイ先には Vercel を使用しました。久しぶりに使いましたが、 Github のリポジトリと連携するだけで自動デプロイしてくれるし、サーバ側のログもすぐに確認できるようになっていたりと開発体験はかなり良かったです。 Analytics や Speed Insights なども簡単に設定・確認できるのも良きでした。
- フレームワーク: Next.js (14.1.0)
- 言語: TypeScript
- スタイル: Tailwind CSS
- デプロイ先: Vercel
- デザイン: Figma
- API: Hacker News API
デザイン
今回デザインは一応 Figma で簡単に作ってから実装に進みました。 (Figama はずっと雰囲気で使ってるのでそろそろちゃんとした使い方を勉強したい...)
ディレクトリ構成
ディレクトリ構成は以下の通りです。今回は App Router を使用しているので app/
ディレクトリ配下にディレクトリやファイル名のルールに基づいて配置していく形になっています。 App Router では not-found.tsx
や opengraph-image.png
など特定のファイル名をつけるだけで勝手に 404 ページや OGP 画像として設定してくれるので、余計なコーディングをする必要がなくて良かったです。
app/
├── [story]
│ └── page.tsx // `/[story]` というパスで表示されるページ
├── apple-icon.png // iOS用のアイコン画像
├── icon.png // Favicon画像
├── item
│ └── [id]
│ └── page.tsx // `/item/[id]` というパスで表示されるページ
├── layout.tsx // 各ページで共通のレイアウト
├── lib
│ ├── apis.ts // APIを叩く関数
│ ├── constants.ts // 定数
│ ├── types.ts // 型定義
│ └── utils.ts // ユーティリティ関数
├── not-found.tsx // 404ページ
├── opengraph-image.png // OGP画像
└── ui // コンポーネント群
├── cards.tsx
├── comment.tsx
...
実装解説
ここからは story ページを例に実際のコードを見ながら解説してみます。
Page
まず、 Page
コンポーネントでは StoryType
(new
, top
, best
, ask
, show
, job
) をパスパラメータ、ページ番号をクエリパラメータとして受け取ります。その後、 StoryType
に応じた itemId 一覧を返す fetchStories()
を実行し、各情報を Card
コンポーネントを表示する Gird
コンポーネントやページ切り替え用の Pagination
コンポーネントに渡すといった流れになっています。この時、不正なパスパラメータやクエリパラメータを受け取った場合は notFound()
を呼んで 404 ページにルーティングされるようになっています。
export default async function Page({
params,
searchParams,
}: {
params: { story: StoryType };
searchParams: { page?: string };
}) {
const isValidPath = STORY_TYPE.includes(params.story);
if (!isValidPath) {
notFound();
}
const itemIds = await fetchStories(params.story);
const currentPage = validatePageNum(searchParams?.page);
const totalPages = Math.ceil(itemIds.length / PAGE_ITEM_SIZE);
if (currentPage > totalPages) {
notFound();
}
return (
<main className="my-10 flex flex-grow flex-col items-center justify-center gap-10">
<Grid itemIds={itemIds} currentPage={currentPage} />
<Pagination totalPages={totalPages} />
</main>
);
}
fetchStories()
Page
コンポーネントで実行している fetchStories()
は以下のようになっています。
App Router では fetch API が拡張されており、 fetch()
に与えるオプションでキャッシュの制御ができるようになっています。今回は StoryType
が new
の場合は常に最新の item 一覧が表示できるようにしたかったので cache: 'no-cache'
を指定し、それ以外は 1 時間キャッシュを保持するように next: { revalidate: STORY_REVALIDATION_SEC }
(STORY_REVALIDATION_SEC = 3600) というオプションを指定しています。
export async function fetchStories(story: StoryType): Promise<ItemId[]> {
try {
const option =
story === 'new'
? {
cache: 'no-cache' as RequestCache,
}
: {
next: { revalidate: STORY_REVALIDATION_SEC },
};
const response = await fetch(
`${ENDPOINT_URL}/${story}stories.json`,
option,
);
if (!response.ok) {
throw new Error(`[fetchStories] error status code: ${response.status}`);
}
return response.json();
} catch (error) {
console.error(error);
return [];
}
}
Grid
続いて Grid
コンポーネントでは現在のページ番号に対応する itemIds
を取り出してそれらを Card
コンポーネントとして map しています。この時、Card
コンポーネントごとに Suspense
で囲むことで Card
コンポーネントが表示できる状態になるまでは CardSkeleton
が表示されるようになっています。 ここで Grid
全体を Suspense
で囲んでも良かったのですが、この後紹介する OGP 画像の URL の取得を行う際にサイトによってレスポンス速度にバラつきが大きかったため、表示できるカードから先に表示してほしいという意図で Card
コンポーネントごとに Suspense
で囲むことにしました。(実際にアクセスしてもらうと徐々にカードが表示される挙動が確認できると思います)
export function Grid({
itemIds,
currentPage,
}: {
itemIds: ItemId[];
currentPage: number;
}) {
return (
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 xl:grid-cols-3">
{itemIds
.slice((currentPage - 1) * PAGE_ITEM_SIZE, currentPage * PAGE_ITEM_SIZE)
.map((itemId) => {
return (
<Suspense key={itemId} fallback={<CardSkeleton />}>
<Card itemId={itemId} />
</Suspense>
);
})}
</div>
);
}
Card
Card
コンポーネントでは、 item 本体の情報と OGP 画像 URL の fetch を行った上で、リンクカードとして構成していきます。 App Router では上位層でまとめて fetch を行ってから子コンポーネントに渡すのではなく、このように各コンポーネント内で直接 fetch を行うのが推奨されているみたいです。(複数コンポーネント間で同じリクエストを呼んでいる場合は裏側でリクエストをまとめて実行してくれるらしい)
export async function Card({ itemId }: { itemId: ItemId }) {
const item = await fetchItem(itemId);
if (!item || item.dead || item.deleted) return null;
const ogpImageUrl = item.url
? await fetchOgpImageUrl(item.url)
: '/opengraph-image.png';
return (
<Link
href={`/item/${itemId}`}
className="grid-item col-span-1 w-80 rounded-xl bg-white shadow-md hover:shadow-xl"
>
<OgpImage
title={item.title}
url={ogpImageUrl || '/opengraph-image.png'}
/>
<div className="flex h-40 flex-col justify-between p-2">
<div className="flex flex-col gap-1">
<span className="text-xs text-gray">{formatTimeAgo(item.time)}</span>
<p className="font-bold">{item.title}</p>
</div>
<div className="flex w-full items-center justify-between text-sm text-gray">
<Icons score={item.score} descendants={item.descendants} />
<span>{`by ${item.by}`}</span>
</div>
</div>
</Link>
);
}
OgpImage
ここまで紹介したコンポーネントは全て Server コンポーネントでしたが、リンクカードを構成するにあたり OGP 画像表示部分だけ Client コンポーネントとして切り出しています。これは画像の読み込みに失敗した際に onError
イベントハンドラを使って代替画像を表示させたかったのですが、イベントハンドラは Server コンポーネントでは実行できないため、このように Client コンポーネントとして切り出す必要がありました。 App Router ではデフォルトは Server コンポーネントとして認識され、ファイルの先頭に 'use client';
をつけることで Client コンポーネントとして使用できます。
'use client';
export function OgpImage({ title, url }: { title: string; url: string }) {
return (
<img
src={url}
alt={title}
className="h-40 w-full rounded-t-xl object-cover"
width={320}
height={160}
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = '/opengraph-image.png';
}}
/>
);
}
fetchOgpImageUrl()
こちらは Card
コンポーネント内で実行していた外部サイトから OGP 画像の URL を取得する関数です。基本的な実装に関しては こちらの記事 を参考にさせていただきました。
また、デプロイしてから気づいたのですが Vercel の hobby プランだと Max duration が 10 秒となっており、外部サイトからのレスポンスが 10 秒以上かかるようなケースで HTTP コネクションがタイムアウトする問題が発生しました。
タイムアウトするとクライアント側でこのような画面が表示されてしまう
今回は hobby プラン内で何とかしたかったので、 fetch のオプションで AbortSignal.timeout()
を与えてレスポンスに 8 秒以上かかったら途中でキャンセルして代替画像を表示するようにすることで回避しました。
export async function fetchOgpImageUrl(
url: string,
): Promise<string | undefined> {
try {
const encodedUri = encodeURI(url);
const headers = { 'User-Agent': 'bot' };
const response = await fetch(encodedUri, {
headers: headers,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(
`[fetchOgpImageUrl] error status code: ${response.status} (${url})`,
);
}
const html = await response.text();
const dom = new JSDOM(html, { virtualConsole });
const meta = dom.window.document.head.querySelectorAll('meta');
const ogp = Array.from(meta)
.filter((element: Element) => element.hasAttribute('property'))
.reduce((previous: any, current: Element) => {
const property = current.getAttribute('property')?.trim();
if (!property) return;
const content = current.getAttribute('content');
previous[property] = content;
return previous;
}, {});
return ogp['og:image'] as string;
} catch (error) {
console.error(error);
}
}
終わりに
今回 App Router で Hacker News Reader を作ってみましたが、 App Router の概要はなんとなく理解できたかなと思います。ただ、キャッシュ周りの挙動や generateStaticParams
を使った一部ページの SSG (/[story]
ページを SSG してみようと思ったけど上手く動かなかった...) などまだよくわかっていないトピックたくさんあるので、完全理解はまだ遠い気がしてます。
また、 App Router 時代になって部分的なレンダリングやより細かいキャッシュ制御などにより、最適化されてより高パフォーマンスな Web サイトができる一方で、開発者側にも求められる知識やメンタルモデルの切り替えが必要になるのでいきなり実務レベルの開発で導入するのは結構難しいのかなと思いました。実際、新規開発でもあえて App Router の採用を見送ったケースやそもそも Next.js 使わないといった記事もいくつか見かけたので、個人的には今後の動向を見守りつつ細々キャッチアップしていこうと思っています。
ここまで読んでいただきありがとうございました!
Discussion