はじめに
どうも、 yoshii です。
Next.js の記事です。
自分のホームページとかに Qiita の最新記事へのリンクを貼れたらいいなと思ったので、簡単に作ってみます。
今回は ISR という Next.js の機能を使って、高速に、かつAPIの制限に引っかかることなく、かつ更新のたびに手動でビルドする必要なく表示します。
また、 OGP 画像も取得して表示してみます。
この記事で作るもの
こんな感じです。
これらを getStaticProps
から Qiita API にアクセスして取得します。
GitHub
コードだけ見たいって人はこちらのリポジトリを見てください。
クローンして動かす際は .env
の BEARER_TOKEN
に各自で取得したAPIアクセストークンを入れて試しましょう。
データフェッチの方針
各方針のわかりやすい説明は賢い人が書いてくれているので読みましょう。
今回のパターンでは ISR(Incremental Static Regeneration) を使います。
SSR(サーバーサイドレンダリング)すると、アクセスするたびにAPIにアクセスが走るため、大して更新頻度も高くないのに悪い人がめちゃくちゃアクセスするとAPIの制限に引っかかる可能性があります。
SSG(スタティックサイトジェネレーション)だと Qiita に更新があるたびにビルドしなければなりません。
ISR なら、自分で設定した間隔でビルドを行うことが可能になります。
Qiita の記事の表示に厳密な最新情報は不要だと思うので、 ISR で問題ないでしょう。
もし Qiita の記事の投稿を監視するような仕組みを作りたいのであれば、こういうのと webhook とか組み合わせたらできるんですかね…
この辺のいいアイデアある人はコメントください。
実装手順
APIアクセストークンの取得
アクセストークンを取得する手順を解説します。
1. Qiita の右上のアイコンをクリックして「設定」を選択
2. 左の一覧から「アプリケーション」を選択
個人用アクセストークンの「新しくトークンを発行する」を押しましょう。
3. スコープで「read_qiita」を選んで「発行する」を選択
説明は適当でいいです。
スコープは、今回記事の取得にしか使わないため、read_qiitaのみです。他のAPIも使いたい人は適宜追加してください。
4. 個人用アクセストークンを保存
表示されてる文字列は後で使うため、メモ帳でも良いのでどこかに保存しておきましょう。
これでアクセストークンの発行完了です。
Next.js プロジェクトの初期化
普通に yarn create next-app
しても良いんですが、筆者が過去に書いたこちらの記事に、筆者お気に入りの Lint 設定を追加したテンプレートプロジェクトのリポジトリへのリンクがあるので、適当に clone するなりして使用するのをおすすめします。
TailwindCSS と React の相性は抜群だと思っているので、筆者が Next.js プロジェクトを初期化する際はいつもこのプロジェクトを使用しています。
よかったら使ってください。
上記のプロジェクトの設定でやっているので、ベースのディレクトリが src
になっていたりしますが、 yarn create next-app
でやる人は適宜読み替えてください。
ライブラリの追加
準備は整ったので、まずは最新記事を取得して表示してみましょう。
必要なライブラリを入れます。
yarn add ky jsdom dayjs
yarn add -D @types/jsdom
各ライブラリの簡単な説明を以下にまとめます。
ky
http Client です。別に axios
とかでもいいですが、筆者は ky
推しです。
jsdom
OGP画像を取得するために使います。
以下の記事の方法でOGP画像を取得するのですが、node.jsだと DOMParser
がないよって怒られるので jsdom
を追加して対処します。
dayjs
ISR の動作をわかりやすくするために使います。
日付のフォーマットのために使うのですが、いらない人は普通に js デフォルトの Date
使って良いです。
トークンと URL を環境変数で定義
Next.js だとごちゃごちゃ設定せずに .env
を読み込んでくれるので以下のファイルを用意するだけで良いです。
QIITA_API_URL=https://qiita.com/api/v2/authenticated_user/items
BEARER_TOKEN={トークン}
Next.js の環境変数について詳しく知りたい人は以下のドキュメントを読みましょう。
Qiita API の authenticated_user/items
にアクセスすることで、認証中のユーザーの記事情報を取得できます。
API のドキュメントは以下です。
レスポンスの型を用意
Qiita API の items エンドポイントのレスポンスの型 QiitaItemResponse
と、レスポンスを実際に表示するのに使いそうな形に変換した 型 ParsedQiitaItem
を定義します。
わざわざレスポンスと表示に使うデータで型を分ける意図としては、 rendered_body
の文字数がエグいので、これをフロントエンドに持ち込みたくないというのが主ですね。
export type QiitaItemResponse = {
coediting: boolean;
comments_count: number;
created_at: string;
id: string;
likes_count: number;
page_views_count: number;
private: boolean;
reactions_count: number;
rendered_body: string;
tags: { name: string; versions: [] }[];
title: string;
updated_at: string;
url: string;
user: {
description: string;
facebook_id: string;
followees_count: number;
followers_count: number;
github_login_name: string;
id: string;
items_count: number;
linkedin_id: string;
location: string;
name: string;
organization: string;
permanent_id: number;
profile_image_url: string;
team_only: boolean;
twitter_screen_name: string;
website_url: string;
};
};
export type ParsedQiitaItem = {
coediting: boolean;
comments_count: number;
created_at: string;
id: string;
likes_count: number;
ogpImageUrl: string;
page_views_count: number;
private: boolean;
reactions_count: number;
tags: { name: string; versions: [] }[];
title: string;
updated_at: string;
url: string;
};
OGPの画像を表示できるように設定
next/image
コンポーネントでOGP画像を表示できるように、 next.config.js
の images.domains
プロパティに OGP 画像のドメインを追加します。
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["qiita-user-contents.imgix.net"],
},
reactStrictMode: true,
};
module.exports = nextConfig;
next/image
の domains については以下のドキュメントにも書かれています。
記事情報を取得して表示
ようやく記事情報を表示します。
コードは以下です。
import dayjs from "dayjs";
import { JSDOM } from "jsdom";
import ky from "ky";
import Image from "next/image";
import { ParsedQiitaItem, QiitaItemResponse } from "types";
import type { GetStaticProps, NextPage } from "next";
type HomeProps = {
generatedAt: string;
qiitaItems: ParsedQiitaItem[];
};
const Home: NextPage<HomeProps> = ({ qiitaItems, generatedAt }) => {
return (
<div>
<h1>更新日時: {generatedAt}</h1>
<div>
{qiitaItems.map(({ id, likes_count, ogpImageUrl, url, title }) => {
return (
<div key={id}>
<a href={url} rel="noreferrer" target="_blank">
<Image
alt={`${title}のOGP画像`}
height={630}
layout="responsive"
src={ogpImageUrl}
width={1200}
/>
</a>
<h2>{likes_count} LGTM</h2>
</div>
);
})}
</div>
</div>
);
};
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
const jsdom = new JSDOM();
const apiUrl = `${process.env.QIITA_API_URL}?per_page=4`;
const res = await ky.get(apiUrl, {
headers: {
Authorization: `Bearer ${process.env.BEARER_TOKEN}`,
},
});
const qiitaItems = (await res.json()) as QiitaItemResponse[];
const ogpUrls: string[] = [];
for (let i = 0; i < qiitaItems.length; i++) {
const { url } = qiitaItems[i];
const res = await ky.get(url);
const text = await res.text();
const el = new jsdom.window.DOMParser().parseFromString(text, "text/html");
const headEls = el.head.children;
Array.from(headEls).map((v) => {
const prop = v.getAttribute("property");
if (!prop) return;
if (prop === "og:image") {
ogpUrls.push(v.getAttribute("content") ?? "");
}
});
}
const parsedQiitaItems: ParsedQiitaItem[] = qiitaItems.map(
(
{
coediting,
comments_count,
created_at,
id,
likes_count,
page_views_count,
tags,
title,
updated_at,
url,
reactions_count,
private: _private,
},
i,
) => {
const parsedItem: ParsedQiitaItem = {
coediting,
comments_count,
created_at,
id,
likes_count,
ogpImageUrl: ogpUrls[i],
page_views_count,
private: _private,
reactions_count,
tags,
title,
updated_at,
url,
};
return parsedItem;
},
);
const generatedAt = dayjs().format("YYYY-MM-DD HH:mm:ss");
return {
props: { generatedAt, qiitaItems: parsedQiitaItems },
revalidate: 60 * 10,
};
};
export default Home;
これで、 yarn dev
して http://localhost:3000/ にアクセスすると…
キタ━━━━(゚∀゚)━━━━!!
処理の流れを簡単に説明します。
1. getStaticProps
で Qiita API から記事の情報を取得
ここで最新記事4件の情報を配列で取得しています。
const apiUrl = `${process.env.QIITA_API_URL}?per_page=4`;
const res = await ky.get(apiUrl, {
headers: {
Authorization: `Bearer ${process.env.BEARER_TOKEN}`,
},
});
const qiitaItems = (await res.json()) as QiitaItemResponse[];
2. 各記事のOGP画像を取得
各記事のURLから DOMParser
で og:image
のURLを取得して、 ogpUrls
に配列として格納しています。
const ogpUrls: string[] = [];
for (let i = 0; i < qiitaItems.length; i++) {
const { url } = qiitaItems[i];
const res = await ky.get(url);
const text = await res.text();
const el = new jsdom.window.DOMParser().parseFromString(text, "text/html");
const headEls = el.head.children;
Array.from(headEls).map((v) => {
const prop = v.getAttribute("property");
if (!prop) return;
if (prop === "og:image") {
ogpUrls.push(v.getAttribute("content") ?? "");
}
});
}
3. 表示用に記事情報を変換
APIから返却されたデータから不要なプロパティを削除して、さっき取得した OGP 画像の URL を含めたものを表示用の情報として新たに定義します。
const parsedQiitaItems: ParsedQiitaItem[] = qiitaItems.map(
(
{
coediting,
comments_count,
created_at,
id,
likes_count,
page_views_count,
tags,
title,
updated_at,
url,
reactions_count,
private: _private,
},
i,
) => {
const parsedItem: ParsedQiitaItem = {
coediting,
comments_count,
created_at,
id,
likes_count,
ogpImageUrl: ogpUrls[i],
page_views_count,
private: _private,
reactions_count,
tags,
title,
updated_at,
url,
};
return parsedItem;
},
);
4. getStaticProps
の戻り値でISRを有効化
generatedAt
で、再生成された日時を取得しています。
revalidate
で再生成する間隔を決めています。
60 * 10 秒ごとにアクセスがあれば再生成を行うということなので、最短でも10分以上の間隔をあけて再生成が行われるという感じです。
正直、Qiitaの記事なんてどれだけ頻繁でも1日に1記事くらいだと思うので、実際はもっと長くても良いと思いますが、今回は動作が分かりやすくなるように10分に設定しています。
const generatedAt = dayjs().format("YYYY-MM-DD HH:mm:ss");
return {
props: { generatedAt, qiitaItems: parsedQiitaItems },
revalidate: 60 * 10,
};
Qiita API の利用制限については以下のドキュメントに書かれています。
認証していれば1時間に1000回呼び出せるらしいので、単純計算で revalidate: 60 * 0.06
までは安全に使えると考えて良いと思います。
まあ、こんなギリギリを攻める意味はないですが。
5. getStaticProps
から受け取った情報の表示
あとは、フロントエンドで情報を表示するのみです。
generatedAt
に、これらの情報が更新された日時が格納されています。
ただし、ISR は production 環境でのみ有効となるため、yarn dev
では常に現在時刻が表示されるはずです。
動作確認については後述します。
const Home: NextPage<HomeProps> = ({ qiitaItems, generatedAt }) => {
return (
<div>
<h1>更新日時: {generatedAt}</h1>
<div>
{qiitaItems.map(({ id, likes_count, ogpImageUrl, url, title }) => {
return (
<div key={id}>
<a href={url} rel="noreferrer" target="_blank">
<Image
alt={`${title}のOGP画像`}
height={630}
layout="responsive"
src={ogpImageUrl}
width={1200}
/>
</a>
<h2>{likes_count} LGTM</h2>
</div>
);
})}
</div>
</div>
);
};
ISR の動作確認
ISR の動作をローカルサーバーで確認するには、以下のコマンドで起動して、 http://localhost:3000/ にアクセスします。
yarn build
yarn start
すると、更新日時の表示が以下のようになるはずです。
getStaticProps
の revalidate には 60 * 10 を指定したものとします。
- 【初回アクセス】 ビルド時の日時が表示される
- 【次回以降 10 分以内のアクセス】 変わらずビルド時の日時が表示される
- 【10 分以降のアクセス】 変わらずビルド時の日時が表示される
- ただしサーバーサイドではビルドが走っている
- 【次のアクセス】 前回のアクセス時の日時が新たに表示される
したがって、3以降にアクセスした場合、3のタイミングの Qiita の最新情報が新たに表示されるということです。
これにより、そこそこ正確に最新情報を取得し、かつ高速に表示を行えるというわけです。
TailwindCSS で簡単にデザインを設定
TailwindCSS で、簡単にスタイルを適用してみましょう。
まずは、Qiitaの色を tailwind.config.js
に追加します。
/**
* @type {import('@types/tailwindcss/tailwind-config').TailwindConfig}
*/
module.exports = {
content: ["./src/pages/**/*.{js,ts,jsx,tsx}", "./src/components/**/*.{js,ts,jsx,tsx}"],
plugins: [],
theme: {
extend: {
colors: {
qiita: "#59bb0c",
},
},
},
};
そして、 src/pages/index.tsx
に className を追加します。
const Home: NextPage<HomeProps> = ({ qiitaItems, generatedAt }) => {
return (
<div className="mx-auto max-w-screen-md">
<h1>更新日時: {generatedAt}</h1>
<div className="flex flex-wrap gap-y-12">
{qiitaItems.map(({ id, likes_count, ogpImageUrl, url }, i) => {
return (
<div className={`w-full p-0 sm:w-1/2 ${i % 2 === 0 ? "sm:pr-2" : "sm:pl-2"}`} key={id}>
<a
className="block overflow-hidden rounded-lg border-2 border-gray-300 hover:opacity-50"
href={url}
rel="noreferrer"
target="_blank"
>
<Image
alt="Qiita記事のogp画像"
height={630}
layout="responsive"
src={ogpImageUrl}
width={1200}
/>
</a>
<h2 className="mt-2 h-8 w-24 rounded-lg bg-qiita text-center font-bold leading-8 text-white">
{likes_count} LGTM
</h2>
</div>
);
})}
</div>
</div>
);
};
最終的にはこんな感じです。
いいですね。
最後に
今回は ISR で自分の Qiita の記事情報を表示してみました。
自分のホームページに外部サービスの更新状況を反映させたい時は、そこまで厳密なリアルタイム性は求められないと思うので ISR が便利なんじゃないかなあと思って書いてみました。
質問や、より良い手法等あればコメントで教えてもらえると嬉しいです。
また、友達が少ないので、よかったら Twitter で友達になってください。