Next.js(SSG)のブログで差分を取得してSupabaseへリクエストをしたい

作成日
更新日

背景

Freshで痛い目にあい、Next.jsでも痛い目にあった。

  • Supabaseのリクエストログを調査するで、Freshのときはbotによって毎分リクエストされてしまい、その度にSupabaseAPI を叩くことになってしまい、1時間に60回は最低でも叩かれる感じだった。
  • Next.jsへ移行して、SSGになったことでビルド時のみにSupabaseにアクセスするようになった。
  • だがしかし、ビルドごとに毎回全件のページを再生成するので、300〜400ぐらいの記事があるからそれだけで5時間分じゃん...?!
    • 本当に400件あるなら大変。
    • 2025-04-11-1744323548229
    • あまりにもかなしい
    • あまりにも考えなしすぎる

どうする?

項目 通常のSSG(再デプロイ時) ISR(on-demand再生成)
ページの更新方法 サイト全体を再ビルド・再デプロイ 指定したページだけを個別再生成
ビルド時間 長くなる(全件ビルド) 非常に高速(対象ページのみ)
SupabaseへのAPIアクセス 全記事分 更新対象記事のみ
Vercelの再デプロイ必要? ✅ 必要 ❌ 不要

手順

  1. 対象ページで revalidatePath() を使えるようにする
  2. 対象ページで SSG を有効にする
  3. Supabase の Webhook から ISR API を呼び出す
  4. Vercelの環境変数を設定

ドキュメント

revalidate ルートを作る

app/api/revalidate/route.ts
// 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()
  // ...
}

これらを複合して使う。

src/app/[category]/[slug].ts
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 を新しく作る

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 付きにする

src/lib/post.ts
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 が叩けないのでタグなしで直接取得する

アップロード側

.deno/libs/revalidate.ts
// 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を返してしまう。

/src/libs/fetchWithFallback.ts
/**
 * 環境がビルド時であるかを判定します
 */
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();
    }
}

なおった。

VercelREVALIDATE_SECRET_TOKEN を追加

忘れてた。環境変数に追加して、再デプロイ。いけ!

ミドルウェアでリダイレクトされちゃってる?

おかしい。これを追加したら直った?

middleware.ts
// 適用対象を明確に制限:静的ファイル/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叩きすぎ問題が解決したはず。多分。

Next.js(SSG)のブログで差分を取得してSupabaseへリクエストをしたい-1744340639043

2025-04-01Deno DeployFresh+Supabaseに移行が完了したと思ったら、えらいことになっていて。 これはやばいとSSGに移行中もガンガンbotたちが叩いちゃって(止めておけばよかった)大変なことに。

最終的に revalidate = 3600revalidate = 604800 にしちゃった。(7日) でも変更があったファイルは即時に変更されるので、何も困らないはず。

サイトアイコン
公開日
更新日