Next.js(SSG)のブログで差分を取得してSupabaseへリクエストをしたい
背景
Freshで痛い目にあい、Next.jsでも痛い目にあった。
- Supabaseのリクエストログを調査するで、Freshのときはbotによって毎分リクエストされてしまい、その度にSupabase の API を叩くことになってしまい、1時間に60回は最低でも叩かれる感じだった。
- Next.jsへ移行して、SSGになったことでビルド時のみにSupabaseにアクセスするようになった。
- だがしかし、ビルドごとに毎回全件のページを再生成するので、300〜400ぐらいの記事があるからそれだけで5時間分じゃん...?!
どうする?
- On-demand ISR(Incremental Static Regeneration)を使うといいらしい。
- ISRを使えば再デプロイは不要で、更新対象の記事のみを更新することができる。
項目 | 通常のSSG(再デプロイ時) | ISR(on-demand再生成) |
---|---|---|
ページの更新方法 | サイト全体を再ビルド・再デプロイ | 指定したページだけを個別再生成 |
ビルド時間 | 長くなる(全件ビルド) | 非常に高速(対象ページのみ) |
SupabaseへのAPIアクセス | 全記事分 | 更新対象記事のみ |
Vercelの再デプロイ必要? | ✅ 必要 | ❌ 不要 |
手順
- 対象ページで
revalidatePath()
を使えるようにする - 対象ページで SSG を有効にする
- Supabase の Webhook から ISR API を呼び出す
- Vercelの環境変数を設定
ドキュメント
revalidate
ルートを作る
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { category, slug, tags = [], secret } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET_TOKEN) {
return NextResponse.json({ message: "Invalid token" }, { status: 401 });
}
if (!slug || !category) {
return NextResponse.json({ message: "Missing slug or category" }, { status: 400 });
}
const path = `/${category}/${slug}`;
revalidatePath(path);
for (const tag of tags) {
revalidateTag(tag);
}
return NextResponse.json({
revalidated: true,
path,
tags,
});
}
この REVALIDATE_SECRET_TOKEN
は自分で適当に生成する
openssl rand -hex 32
これは Next.js の環境変数と GitHub のSecretsの両方に設定する。
対象ページで SSG / ISR を有効にする
src/app/[category]/[slug]/page.tsx
に書く。
時間ごとに再生成する方法
export const revalidate = 3600; // 1時間ごとに再生成
export const dynamicParams = true;
データ単位で再生成する方法
export default async function Page() {
const data = await fetch('https://api.vercel.app/blog', {
next: { tags: ['posts'] },
})
const posts = await data.json()
// ...
}
これらを複合して使う。
import MobileBottomMenu from "@/components/interactive/MobileBottomMenu";
import Overview from "@/components/interactive/Overview";
import PostContent from "@/components/organisms/PostContent";
import { extractIntroAndH2sForOGP } from "@/libs/excerpt";
import { generateOGPMetadata } from "@/libs/generateOGPMetadata";
import { getPost } from "@/libs/post";
import { getPostIndexes } from "@/libs/postIndex";
import { Category } from "@/types/category";
import { PostIndex } from "@/types/postIndex";
import { notFound } from "next/navigation";
type Props = {
params: Promise<{ category: string; slug: string }>;
};
// ✅ ISR を有効にする
export const dynamicParams = true; // generateStaticParams にない slug も動的生成可
export const revalidate = 3600; // 1時間キャッシュ後に自動再生成 + 手動revalidate対応
// ✅ ビルド時に生成する静的パス(初期のSSG対象)
export async function generateStaticParams() {
const categories: Category[] = ["posts", "map", "notes", "weekly-reports"];
const all = await Promise.all(
categories.map((category) => getPostIndexes(category)),
);
return all.flatMap((posts) =>
posts?.map((post) => ({
category: post.category,
slug: post.slug,
})) ?? []
);
}
// ✅ OGP 用メタデータを取得(ISR 対象)
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const decodedSlug = decodeURIComponent(slug);
const post = await getPost(decodedSlug);
if (!post) return {};
return generateOGPMetadata({
title: post.title,
description: extractIntroAndH2sForOGP(post.content, 4),
url: `${process.env.NEXT_PUBLIC_SITE_URL}/posts/${post.category}/${post.slug}`,
imageUrl: post.coverImage
? `/api/images/${post.coverImage}`
: undefined,
});
}
// ✅ ページ本体(Post本文 + サイドバー)
export default async function Page({ params }: Props) {
const { slug } = await params;
const decodedSlug = decodeURIComponent(slug);
const post = await getPost(decodedSlug); // ここの中身がタグ付きに変わる
if (!post) notFound();
const categories: Category[] = ["posts", "map", "notes", "weekly-reports"];
const entries = await Promise.all(
categories.map(async (category): Promise<[Category, PostIndex[]]> => {
const allPostIndexs = (await getPostIndexes(category)) ?? [];
return [category, allPostIndexs];
}),
);
return (
<>
<PostContent post={post} />
<aside className="xl:w-1/4 p-4 xl:max-w-xs relative">
<Overview post={post} />
</aside>
<div className="lg:hidden">
<MobileBottomMenu post={post} postMap={entries} />
</div>
</>
);
}
Supabaseからの記事の取得方法を変える
API経由の fetch に変える(タグ付き fetchにするため)
src/posts/[slug]/route.ts
を新しく作る
import { supabase } from "@/libs/supabase";
import { NextResponse } from "next/server";
export async function GET(_: Request, { params }: { params: { slug: string } }) {
const { data, error } = await supabase
.from("articles")
.select(
"title, html, slug, created_at, updated_at, aliases, category, cover_image, url, outdated, archived",
)
.eq("slug", params.slug)
.eq("private", false)
.single();
if (error || !data) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
return NextResponse.json(data);
}
元々の呼び出す方は tag 付きにする
import { supabase } from "@/libs/supabase";
import { Category } from "../types/category";
import { Post } from "../types/post";
import { fetchWithFallback } from "./fetchWithFallback";
export async function getPost(slug: string): Promise<Post | null> {
return await fetchWithFallback<Post | null>(
`/api/posts/${slug}`,
{ next: { tags: [`post:${slugifyFileName(slug)}`] } }, // ISR用のタグ
async () => {
const { data, error } = await supabase
.from("articles")
.select(
"title, html, slug, created_at, updated_at, aliases, category, cover_image, url, outdated, archived"
)
.eq("slug", slug)
.eq("private", false)
.single();
if (error || !data) return null;
return {
title: data.title,
slug: data.slug,
createdDate: new Date(data.created_at),
updatedDate: new Date(data.updated_at),
category: data.category as Category,
content: data.html,
coverImage: data.cover_image,
aliases: data.aliases as string[],
sourceURL: data.url,
outdated: data.outdated ?? false,
archived: data.archived ?? false,
};
}
);
}
PostIndexes も同様に
/api/revalidate
は共通- 対象ページで SSG / ISR を有効にする
- `export const dynamicParams = true;
export const revalidate = 3600;
getPosts
の中身をタグ付きにして内部APIから呼び出す- この時、ビルド中は
/api/posts
が叩けないのでタグなしで直接取得する
- この時、ビルド中は
アップロード側
// Vercel の ISR を使って、差分を通知する
export async function revalidateArticle(slug: string, category: string) {
const REVALIDATE_SECRET = Deno.env.get("REVALIDATE_SECRET_TOKEN")!;
const VERCEL_APP_URL = Deno.env.get("VERCEL_APP_URL")!;
const safeTag = `post:${slugifyFileName(slug)}`;
const body = {
slug,
category,
secret: REVALIDATE_SECRET,
tags: ["articles", `post:${slug}`],
};
try {
const res = await fetch(`${VERCEL_APP_URL}/api/revalidate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(
`❌ Revalidate failed for /${category}/${slug}`,
await res.text(),
);
} else {
console.log(`✅ Revalidated /${category}/${slug}`);
}
} catch (err) {
console.error(`❌ Failed to revalidate /${category}/${slug}`, err);
}
}
tagの方はASCIIのみにしないとうまく動作しない。
// ファイル名を slugify して ASCII のみの形式に変換
export function slugifyFileName(fileName: string): string {
return fileName
.normalize("NFKD") // Unicode 正規化(濁点・半濁点を分離)
.replace(/[\u0300-\u036f]/g, "") // ダイアクリティカルマーク除去
.replace(/[^\w.-]/g, "_") // 許可されていない文字を `_` に変換
.replace(/_{2,}/g, "_") // 連続する `_` を1つに
.toLowerCase();
}
手動で叩いてみる
curl -X POST http://localhost:3000/api/revalidate \
-H "Content-Type: application/json" \
-d '{
"category": "posts",
"slug": "my-article",
"tags": ["articles", "post:my-article"],
"secret": ""
}'
{"revalidated":true,"path":"/posts/zatsu-ni-tukuru","tags":["articles","post:my-article"]}
成功してるっぽい。
デプロイしてみる
エラーが起きた
Export encountered an error on /(main)/[category]/[slug]/page: /posts/how-obsidian-operate, exiting the build.
⨯ Next.js build worker exited with code: 1 and signal: null
Error: Command "npm run build" exited with 1
notFound()
を返してしまう原因は、ビルドタイムかどうかのチェックが間違っていたため。これにより、fallback() に入らず /api/posts
を叩くが、まだ /api
を利用できずに404を返してしまう。
/**
* 環境がビルド時であるかを判定します
*/
export function isBuildTime(): boolean {
return process.env.NEXT_PHASE === "phase-production-build";
}
/**
* fetch が使えない場合に fallback を実行するユーティリティ関数
* @param input - fetch の URL(相対推奨)
* @param options - fetch オプション(ISR tag 含め)
* @param fallback - fetch に失敗した場合の代替処理(Supabase SDKなど)
*/
export async function fetchWithFallback<T>(
input: string,
options: RequestInit & { next?: { tags?: string[] } },
fallback: () => Promise<T>,
): Promise<T> {
try {
if (isBuildTime()) return await fallback();
const res = await fetch(input, options);
if (!res.ok) throw new Error(await res.text());
return await res.json();
} catch (err) {
console.warn(
`⚠️ fetchWithFallback failed for ${input}:`,
(err as Error).message,
);
return await fallback();
}
}
なおった。
Vercel にREVALIDATE_SECRET_TOKEN
を追加
忘れてた。環境変数に追加して、再デプロイ。いけ!
ミドルウェアでリダイレクトされちゃってる?
おかしい。これを追加したら直った?
// 適用対象を明確に制限:静的ファイル/APIなどを除外
export const config = {
matcher: [
"/((?!_next/|api/|static/|favicon.ico|robots.txt|sitemap.xml).*)",
],
};
ワークフローを確認
REVALIDATE_SECRET_TOKEN: ${{ secrets.REVALIDATE_SECRET_TOKEN }}
の secrets
を間違えて env
にしちゃってたからだった。
いけ...! いった!!
本番環境で見る
新しい記事を投稿して反映されること
- このノートを
private: false
にしてアップロードしてみる- アップロードされている!
- このノートを
既存の記事を編集して変更が反映されること
これって3600時間はされない?そんなことはない。- だがしかし反映されぬ。
- そしてなぜか3977件もリクエストが...why...画像...???
revalidatePath
の方は日本語が完全にダメなのでpost:${slugifyFileName(slug)}
に変換する。アップロード側とfetch側の両方でこの値は合わせる必要がある。- これを直したら変更が反映されるようになったかも?
- なった!!!!!!!!!
- これを直したら変更が反映されるようになったかも?
もう一度新規作成ノートも確認する。
- ちゃんと反映された!!!!
ISRはいいぞ
これで、誰も見ていないのにAPI叩きすぎ問題が解決したはず。多分。
2025-04-01にDeno DeployでFresh+Supabaseに移行が完了したと思ったら、えらいことになっていて。 これはやばいとSSGに移行中もガンガンbotたちが叩いちゃって(止めておけばよかった)大変なことに。
最終的に revalidate = 3600
は revalidate = 604800
にしちゃった。(7日)
でも変更があったファイルは即時に変更されるので、何も困らないはず。
