Next.js

【Next.js】初心者向けSSG+Webhook実装

更新日:2026/03/09

Reactでプログラミングしていると時々SSGという言葉を耳にします。
そこで調べてみるとなぜかNext.jsに話題が飛んでしまい、面倒になって思考停止してました。
最近になってようやく思考停止しなくなったので、Next.jsのSSGについて考察およびコードを作成してみます。

 

SSGとは

SSGは『Static Site Generation』の略称で、日本語では『静的サイト生成』という意味です。

初期のWebサイトは全て静的サイトでした。
当時はSSGという言葉はありませんでしたが、非常に多くの入力した文章とHTMLの大枠を記述したテンプレートから静的サイトを生成するアプリがありました。

そして、WordPressを代表としたブラウザからアクセスされる度にHTMLを生成して配信する仕組みが主流になっています。
こちらは動的サイトと呼ばれていますが、仕組みとしては『動的サイト生成』ではなく『サーバーサイドレンダリング(Server-Side Rendering)』になります。
略称はSSRですね。

さらにクライアント側で描画するSPA(Single Page Application)が出ていろいろ複雑になってきたりしますが省略。

この流れからするとSSGは少し古いイメージがありますね。ですがアクセスの度にページを生成する動的サイトと比較して配信までの時間が圧倒的に速いことから、SEOで有利という点が注目されて利用するサイトが増えています。
SSGは静的ファイルを生成することが主眼なので、コンテンツ管理や生成作業をどこでやるかについては関係ありません。

また、配信の仕組みがSSRよりもシンプルなので、意図しないセキュリティの穴ができにくいという特徴があります。

※実際には動的サイトでもキャッシュの最適化やCDN化により静的配信並みの速度で配信可能です。

 

なぜNext.jsでSSG実装?

Next.jsはNode.jsで動作するため、ほとんどのサーバーで実行可能です。
またJSXでの記述ができるため、文字列の組み合わせに終始しがちなHTML生成作業を、コンポーネントの組み立てという視点に置き換えることができます。

JSXはReactでも利用可能ですが、静的HTML生成やルーティング処理などを組み込む必要があります。
一方Next.jsは、ページ単位で自動的に静的HTML生成が行われる仕組みが最初から組み込まれており、特別な設定をしなくてもSSGを実現できます。さらにデータ取得方法を少し変えるだけでSSRやISRにも切り替えられるため、将来的な拡張にも柔軟に対応できます。設定よりもアプリ設計に集中できる点が、React単体ではなくNext.jsでSSGをおすすめする大きな理由です。

さらに重要なのがキャッシュです。
APIから取得したデータをキャッシュする仕組みが組み込まれているので、無駄なAPIアクセスを防ぐことができます。
有料のヘッドレスCMSを利用する場合などは少しでもアクセスを減らすことで費用削減が求められるため、必須機能です。

 

Next.jsのデメリット

Next.jsは、フロントとバックでのやりとりを簡単に構築できることがコンセプトの一つです。
そのため、フロント側でスクリプトを必要としないサイトでもNext.jsが用意したスクリプトが挿入されます。
SEOの視点から不要なスクリプトを削除する必要があるので、デメリットとなります。

実際には軽量なスクリプトのため気にする必要は無いレベルですし、スクリプトにより、必要なJSのみを読み込んだり、リンク先ページの自動先読み等の便利な機能を利用できるので不要とは言い切れません。

とはいえ、「それでも裏で何をやっているのかわからないのは嫌だ」という人は、他の手段を選択したほうが良いでしょう。

 

SSG+Webhook更新の概要

基本はSSGとして静的HTMLを配信して、Webhookでのリクエスト時に該当ページ再生成を行う処理は、次の二点で実現できます。

1. ページコンポーネントに次のコードを記述する。

page.js

export const dynamic = "force-static";

このコードにより、レンダリングのタイミングが①ビルド時および②キャッシュデータの無効化関数(revalidatePath等)が呼び出されたとき静的HTMLが生成されます。

2. ページのデータを revalidate: falseで取得する

fetchによる取得

const res = await fetch("https://....", {
    next: { revalidate: false }
});

DBや外部SDK等による取得

const getPosts = unstable_cache(
  async () => getData(),
  ["posts"],
  { revalidate: false }
);

これにより、二回目以降の呼び出しでキャッシュしたデータを返します。
ただし、無効化関数(revalidatePath等)が呼び出されるとキャッシュが破棄されます。

次のコードでfetch等のキャッシュ制御規定値を指定することも可能です。

page.js

export const revalidate = false;

SSGの再生成トリガー

SSGの再生成は関連するキャッシュを無効化することで、次回呼び出し時に行われます。
キャッシュを無効化する関数をいくつか挙げます。

■revalidatePath:

revalidatePath()は、ページ・レイアウト・API単位でキャッシュデータを無効化します。

revalidatePath( "/post/page1" );

キャッシュがタグ付けされている場合は無効化したページのみに新しいデータが提供されます。
次回アクセス時にキャッシュを返したあとバックグラウンドでキャッシュが更新(SWR:stale-while-revalidate)されます。
キャッシュが更新されるまで、古いキャッシュが返ります。

■revalidateTag:

revalidateTag()は、タグ付けされたキャッシュデータを無効化します。

const posts = await fetch("https://....", {
  next: { tags: ["posts"] },
})


revalidateTag( "posts" , "max" );

revalidateTag()の第二引数に"max"(推奨)を指定すると、revalidatePath()と同じタイミングでキャッシュが更新されます。

■updateTag:

updateTag()は、revalidateTag()と同じようにタグ付けされたキャッシュデータを無効化します。
ただしServer Actions用の関数のため、webhookでは使用できないので今回の用途では使用不可です。

 

準備

Node.jsインストール

Next.jsはNode.jsで動作するので、Node.jsをインストールします。

WindowsとMacは、次のページからインストーラーを取得
Node.jsダウンロード
その他のOSは、次ページを参照
パッケージマネージャを利用した Node.js のインストール

サーバーでルーティングを行う場合は、サーバー側にもNode.jsをインストールしましょう。

Next.jsプロジェクト作成

次のコマンドでNext.jsのインストールおよび、プロジェクトを作成します。

npx create-next-app@latest

質問に回答していきます。
※バージョンにより質問が異なる可能性があります。

? What is your project named? » my-app

「プロジェクト名はなんですか?」

プロジェクト名を入力しましょう

? Would you like to use the recommended Next.js defaults? 
>   Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router
    No, reuse previous settings
    No, customize settings

「推奨されるNext.jsのデフォルトを使用しますか?
> はい、推奨デフォルト(TypeScript、ESLint、Tailwind CSS、App Router)を使用します。
いいえ、以前の設定を再利用します。
いいえ、設定をカスタマイズします。」

上下キーで選択肢を変更できます。

今回は「いいえ、設定をカスタマイズします。」を選択。

? Would you like to use TypeScript? » No / Yes

「TypeScript を使用しますか?」

今回は「No」を選択

? Which linter would you like to use? » - Use arrow-keys. Return to submit.
>   ESLint - More comprehensive lint rules
    Biome
    None

「どのリンターを使用しますか? » - 矢印キーを使用してください。送信するにはリターンキーを押してください。
> ESLint - より包括的なリンタールール
Biome
なし」

今回は「ESLint」を選択

? Would you like to use React Compiler? » No / Yes

「React Compilerを使用しますか? » いいえ / はい」

Next.jsとReact Compilerの組み合わせがあまり安定していないようなので、今回は「No」を選択

? Would you like to use Tailwind CSS? » No / Yes

「Tailwind CSSを使用しますか? » いいえ / はい」

今回は「No」を選択

? Would you like your code inside a `src/` directory? » No / Yes

「コードを`src/`ディレクトリ内に置きますか? » いいえ / はい」

ソースコードをsrcフォルダ内にまとめたいときは「Yes」を選択。
node_moduleやpublic等のフォルダがソースコードのフォルダと混ざって見にくくなるのが嫌なら「Yes」を選択。

今回は「No」を選択

? Would you like to use App Router? (recommended) » No / Yes

「App Routerを使用しますか?(推奨) » いいえ / はい」

App Routerは、appディレクトリをルートとしてルーティングする仕組み。

今回は「Yes」を選択

? Would you like to customize the import alias (`@/*` by default)? » No / Yes

「インポートエイリアス(デフォルトでは `@/*`)をカスタマイズしますか? » いいえ / はい」

今回は「No」を選択

 

作成したコード

今回紹介するコードの最終バージョンおよびデモは、次のリンク先を参照してください。

また、データの取得先として次のデータ配信サーバーを使用します。

最終ファイル構成

最終的なファイル構成は次のようになります。

nextjs-ssg-webhook
 ┣━ .next ←ビルド出力先
 ┣━ app ←コンテンツルート
 ┃   ┣━ api 
 ┃   ┃   ┗━ revalidate  ←/api/revalidate
 ┃   ┃       ┗━ route.js
 ┃   ┣━ posts
 ┃   ┃   ┣━ [slug] ←/posts/xxxx
 ┃   ┃   ┃   ┗━ page.js
 ┃   ┃   ┗━ page.js ←/posts
 ┃   ┣━ favicon.ico
 ┃   ┣━ globals.css
 ┃   ┣━ layout.js ←ルートレイアウト
 ┃   ┣━ not-found.js ←404HTML
 ┃   ┗━ page.js ←Topページ
 ┣━ lib
 ┃   ┣━ data.js ←fetch操作
 ┃   ┣━ fetchtag.js 
 ┃   ┗━ purify.js 
 ┣━ public
 ┣━ sidebar ←共通サイドバー
 ┃   ┗━ sidebar.js 
 ┣━ eslint.config.mjs
 ┣━ jsconfig.json
 ┣━ next.config.mjs
 ┣━ package-lock.json
 ┗━ package.json

App Routerが有効になっているので、appフォルダがコンテンツルート(ドメイン直下のフォルダ)になります。

今回は次のページを作成しました。

フォルダファイルURLパス
トップページ/apppage.jshttp://ドメイン/
記事一覧ページ/app/posts

page.jshttp://ドメイン/posts
記事ページ/app/posts/[slug]

page.jshttp://ドメイン/posts/<slug>

記事ページはデータ配信サーバーから取得した記事データをもとに生成されます。
URLパスの<slug>も、データ配信サーバーで指定されます。

また、次のAPIを設置します。

フォルダファイルURLパス
記事更新用Webhook/app/api/revalidateroute.jshttp://ドメイン/api/revalidate/<slug>

APIを呼び出すと<slug>に対応する記事ページのデータを再取得して記事ページを更新します。
同時に記事一覧ページと、サイドバーの新着一覧を更新します。

データ取得処理

Next.jsはデータをfetchで外部から取得することが推奨されています。
そこで、今回のコード用のデータ取得サーバーを作成しました。
こちらのコードについては、次のリンク先を参照してください。

なお、Next.jsのfetchは既存のfetch APIを拡張したものです。
そのため、Next.js固有のパラメーターを受け付けます。

/lib/data.js

/lib/data.jsは、fetch処理をまとめたコードです。

/lib/data.js

import { TAG_SITE,TAG_TOP,TAG_POSTS,TAG_SLUG,TAG_LATEST } from "./fetchtag";

const fetchURL = (path) => `${process.env.DATA_URL}/api/${path}`;
const headers = {
    "X-API-KEY": process.env.DATA_KEY,
};

const fetchData = async (path, tag) => {
    try {
        const res = await fetch(fetchURL(path), {
            headers,
            next: { tags: [tag], revalidate: false }
        });
        if (!res.ok) {
            if (res.status === 404) notFound()
            throw new Error(`HTTP ${res.status}`)
        }
        const json = await res.json();
        return json.error ? null : json;
    } catch (err) {
        console.error(err)
        return null
    }
};
const getSiteData = async () => {
    return await fetchData("site/", TAG_SITE());
};
const getTopData = async () => {
    return await fetchData("posts/top/", TAG_TOP());
};
const getContentsData = async () => {
    return await fetchData("posts/", TAG_POSTS());
};
const getContentData = async (slug) => {
    return await fetchData(`posts/${slug}`, TAG_SLUG(slug) );
};
const getLatestData = async () => {
    return await fetchData("latest", TAG_LATEST() );
}
export { getTopData, getContentData, getContentsData, getSiteData, getLatestData };

ヘッダーにX-API-KEYをセットしていますが、これは今回使用している配信サーバー固有のものです。
データ取得先の要件に合わせた方法で設定する必要があります。

重要なのが、fetchData関数内の次の行です。

 next: { tags: [tag], revalidate: false }

next.tagで、fetchの取得結果をグループ化しています。
またnext.revalidateにfalseをセットすることで、キャッシュを永続化させています。
こうすることで、revalidateTag( タグ名 )が呼び出されるまで、キャッシュデータが使用されます。

/lib/fetchtag.js

/lib/fetctag.jsは、fetchのタグを定義しています。

/lib/fetchtag.js

export const TAG_SITE = ()=>"site",
    TAG_TOP = ()=>"top",
    TAG_POSTS = ()=>"posts",
    TAG_SLUG = (slug)=>`posts-${slug}`,
    TAG_LATEST = ()=>"latest-posts";

サニタイズ/タグ除去:/lib/purify.js

/lib/purify.jsはデータ取得したHTMLを含む文字列のサニタイズおよび、タグ除去をおこなっています。

/lib/purify.js

import DOMPurify from "isomorphic-dompurify"

function purify(html) {
    if( !html ) return "";
    
    return DOMPurify.sanitize(html, {
        USE_PROFILES: { html: true },
        ALLOWED_TAGS: [
            "p", "br", "strong", "em", "ul", "ol", "li",
            "a", "h1", "h2", "h3", "h4","h5","blockquote"
        ],
        ALLOWED_ATTR: ["href", "target", "rel"]
    })
}
function stripHtmlTags(html){
    if( !html ) return "";

    return html
    .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
    .replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "")
    .replace(/<[^>]+>/g, "")
    .replace(/\s+/g, " ")
    .trim()
}
export {purify,stripHtmlTags};

取得したデータにHTMLタグが含まれていて、そのタグを活かしたい場合、悪意のあるスクリプトを埋め込んでユーザーのブラウザ上で実行させる攻撃(XSS:クロスサイトスクリプティング)を考慮して危険なタグの除去や無効化(サニタイズ)する必要があります。

isomorphic-dompurifyはサーバー側でDOMPurifyを利用するためのライブラリです。
次のコマンドでインストールしましょう。

npm install isomorphic-dompurify

ルートレイアウト

/app/layout.js

/app/layout.jsは、各ページの共通htmlを定義します。

/app/layout.js

import Link from "next/link";
import { Potta_One } from "next/font/google";
import { getSiteData } from "@/lib/data";
import { Sidebar } from "@/sidebar/sidebar";
import {stripHtmlTags} from "@/lib/purify";
import "./globals.css";

const pottaOne = Potta_One({ weight: "400" });

// メタデータの生成
export async function generateMetadata() {
  const siteData = await getSiteData();
  const siteTitle = stripHtmlTags(siteData.data.siteTitle);

  return {
    title: {
      default: siteTitle,
      template: `%s | ${siteTitle}`
    }
  };
}
export default async function RootLayout({ children }) {
  const siteData = await getSiteData();
  const siteTitle = stripHtmlTags(siteData.data.siteTitle);

  return (
    <html lang="ja">
      <body className={`${pottaOne.className}`}>
        <header>--{siteTitle}--</header>
        <div className="wrap">
          <div className="content">
            <Link href="/" className="navi">
              ホームへ戻る
            </Link>
            {children}
          </div>
          <div className="sidebar">
            <Sidebar />
          </div>
        </div>
      </body>
    </html>
  );
}

generateMetadata()は、headタグ内に挿入するメタデータを返します。
title.templateは子ページに適用され、%sが子ページが指定したメタデータのtitleと置き換えられます。
title.templateを使用する場合は、title.defaultが必須です。

RootLayout()は、各ページの共通レイアウトを定義します。
htmlタグを返す必要があります。

上記コードはheadタグが定義されていませんが、generateMetadata()の結果をもとに自動で生成されます。

/app/globals.css

/app/globals.cssは、/app/layout.jsで読み込まれるcssです。

/app/globals.css

header{
  height: 50px;
  background-color: aqua;
  font-size: 2em;
  padding: 1em;
}
.navi{
  font-size: 0.8em;
}
.navi::before{
  content: "▶";
}
.wrap{
  display: flex;
  justify-content: space-between;
  gap: 1em;
  padding: 1em;
}
.content{
  flex: 1;
}
h1{
  border-top:2px solid black;
  border-bottom:2px solid black ;
}

トップページ:/app/page.js

/app/page.jsは、ドメイン直下のページを生成します。

/app/page.js

import Link from "next/link";
import { getSiteData,getTopData } from "@/lib/data";
import {purify,stripHtmlTags} from "@/lib/purify";

export const dynamic = "force-static";

// メタデータの生成
export async function generateMetadata() {
  const siteData = await getSiteData();
  const {siteTitle,siteDescription} = siteData.data;

  return {
    title: stripHtmlTags(siteTitle),
    description: stripHtmlTags(siteDescription)
  };
}
export default async function Page() {
  const post = await getTopData();
  if( !post ) notFound();

  const {title,content} = post.data;

  return (<>
    <h1>{stripHtmlTags(title)}</h1>
    <p>fetch日時:{new Date(post.fetchDate).toLocaleString("ja-JP")}</p>
    <Link href="/posts/">記事一覧</Link>
    <div dangerouslySetInnerHTML={{ __html: purify(content) }} />
  </>);
}

dynamicをエクスポートすると、ページのレンダリング呼び出しを制御できます。

export const dynamic = "force-static";

今回は"force-static";をセットしています。
これにより、SSGが強制されます。

そして、revalidateTag()が呼び出されることで関連するfetchキャッシュが削除され、次のアクセス時にページが再生成されます。

レンダリング時はdefaultエクスポートした関数が呼び出されます。
そのため、ここではPageという関数名で定義していますが、他の名前でも問題ありません。

記事一覧ページ:/app/posts/page.js

/app/posts/page.jsは記事一覧ページです。

/app/posts/page.js

import { getContentsData } from "@/lib/data";
import {purify,stripHtmlTags} from "@/lib/purify";

export const dynamic = "force-static";

export const metadata = {
  title: "コンテンツ一覧",
  description: "コンテンツ一覧です"
};

export default async function Page() {
  const posts = await getContentsData();

  return (
    <main>
      <h1>コンテンツ一覧</h1>
      <p>&nbsp;</p>
      <p>ページ生成日時:{new Date().toLocaleString("ja-JP")}</p>
      <p>fetch日時:{new Date(posts.fetchDate).toLocaleString("ja-JP")}</p>
      <p>&nbsp;</p>
      <ul>
        {posts.data.map(post => (
            <li key={post.slug}>
              <a href={`/posts/${post.slug}`}>
                {stripHtmlTags(post.title)}
              </a>:{stripHtmlTags(post.content).slice(0, 120) + "..."}
            </li>
          ))}
      </ul>
    </main>
  );
}

メタデータが固定値のときは、export const metadata={} で指定できます。
動的な値のときは、トップページのようにgenerateMetadata()をエクスポートしましょう。

記事ページ:/app/posts/[slug]/page.js

/app/posts/[slug]/page.jsは記事ページです。

/app/posts/[slug]/page.js

import { getContentData,getContentsData } from "@/lib/data";
import { notFound } from "next/navigation";
import {purify,stripHtmlTags} from "@/lib/purify";

export const dynamic = "force-static";

const getData = async (params)=>{
  const {slug} = await params;

  const post = await getContentData(slug);
  if( !post ) notFound();

  return post;
};

export async function generateStaticParams() {
  const posts = await getContentsData();

  return posts.data.map(post => ({
    slug: post.slug,
  }));
}

  // メタデータの生成
export async function generateMetadata({ params }) {
  const post = await getData(params);
  const title = stripHtmlTags(post.data.title);
  const content = purify(post.data.content);

  return {
    title: title,
    description: content.slice(0, 120),
    openGraph: {
      title: title,
      description: content.slice(0, 120),
    }
  };
}
export default async function Page({ params }) {
  const post = await getData(params);
  const title = stripHtmlTags(post.data.title);
  const content = purify(post.data.content);
  const slug = post.data.slug;

  return (
    <main>
      <h1>{title}</h1>
      <p>&nbsp;</p>
      <div dangerouslySetInnerHTML={{ __html: content }} />
      <p>ページ生成日時:{new Date().toLocaleString("ja-JP")}</p>
      <p>fetch日時:{new Date(post.fetchDate).toLocaleString("ja-JP")}</p>
      <p>&nbsp;</p>
      <p><a href={`/api/revalidate?slug=${slug}&secret=${process.env.SECRET_KEY}`} target="_blank">[再生成]</a></p>
      <p>※再生成クリック後ブラウザ再読み込み</p>
    </main>
  );
}

Next.jsのApp Routerは、[]で囲われたフォルダ名を使用することで動的なルーティングをおこなえます。
今回は、https://ドメイン名/post/post1やhttps://ドメイン名/post/post2などと一致します。

動的ルーティングを行う場合は、generateStaticParams()を定義して動的パラメーターに一致する値の一覧を確定させる必要があります。
今回は次のように、slugに一致する値を指定します。

export async function generateStaticParams() {
  const posts = await getContentsData();

  return posts.data.map(post => ({
    slug: post.slug,
  }));
}

[slug]に一致する値は、レンダリング関数やgenerateMetadata()の引数で取得できます。
関数が受け取ったparamsはPromiseなのでawaitで待ち、結果からslugを取り出します。

export default async function Page({ params }) {
  const {slug} = await params;

記事ページでは、少し強引ですがページを更新するWebhookへのリンクを設置しています。

      <p><a href={`/api/revalidate?slug=${slug}&secret=${process.env.SECRET_KEY}`} target="_blank">[再生成]</a></p>

リンクをクリックすると新しいタブが開いて結果のJSONが表示されます。
元のページに戻って再読み込みすると、サーバー側でページが再生成されます。

/sidebar/sidebar.jsは、共通サイドバーです。
ここでは配信サーバーから新着情報を受け取って、レンダリングしています。

/sidebar/sidebar.js

import { getLatestData } from "@/lib/data";
import {stripHtmlTags} from "@/lib/purify";

export async function Sidebar(){
    const latest = await getLatestData();
    return (<div>
        <p>新着一覧</p>
        <p>fetch日時:{new Date(latest.fetchDate).toLocaleString("ja-JP")}</p>
        <ul>
        {latest.data.map(l => (
            <li key={l.slug}>
              <a href={`/posts/${l.slug}`}>
                {stripHtmlTags(l.title)}
              </a>:({(new Date( l.date )).toLocaleDateString("ja-JP")})
            </li>
          ))}
      </ul>
    </div>);
}

404ページ:/app/not-found.js

/app/not-found.jsは、リクエストがappフォルダの構成と一致しなかったり、NotFound()が呼び出されたときのレンダリング関数を定義します。

/app/not-found.js

export default function NotFound() {
  return (
    <main>
      <h1>404</h1>
      <p>お探しのページは存在しません</p>
    </main>
  );
}

Webhook: /app/revalidate/route.js

/app/revalidate/route.jsは、記事更新用のAPI呼び出しを定義しています。

/app/revalidate/route.js

import { revalidateTag } from "next/cache";
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { TAG_POSTS, TAG_SLUG, TAG_LATEST } from "@/lib/fetchtag";

let lastRequestTime = 0;

export async function GET(req) {

  // 5秒に一回のみ受け付け
  const now = Date.now();
  if (now - lastRequestTime < 5000) {
    return Response.json(
      { message: "Too Many Requests" },
      { status: 429 }
    );
  }
  lastRequestTime = now;

  const headerList = await headers();
  if (headerList.get("origin") === process.env.SITE_URL) {
    return Response.json({ message: "Invalid origin" }, { status: 401 });
  }

  const searchParams = req.nextUrl.searchParams;

  const secret = searchParams.get("secret");
  if (secret !== process.env.SECRET_KEY) {
    return Response.json({ message: "Invalid secret" }, { status: 401 });
  }

  const slug = searchParams.get("slug");
  if (!slug) {
    return Response.json({ message: "Invalid slug" }, { status: 401 });
  }
  revalidateTag(TAG_SLUG(slug), "max");
  revalidateTag(TAG_POSTS(), "max");
  revalidateTag(TAG_LATEST(), "max");

  return NextResponse.json({ success: true, slug: slug });
}

本来ならPOSTで受け取るべきですが、送信側のコードを可能な限り減らしたかったのでGETで受け取っています。

このAPIは5秒に一回だけ受け付けます。
呼び出し元が複数でも、5秒に一回だけです。
デモとして公開しているのと、リンククリックによる呼び出しという無謀なこと(記事ページ:/app/posts/[slug]/page.js)をやっているので制限しています。

一通りチェックが終わったらrevalidateTag()を呼び出して、タグ名に対応するキャッシュを無効化します。
ここでは次の3つのキャッシュを無効化しています。

1.slugに対応したページ
2.記事一覧
3.サイドバーの新着情報

1と2より、該当するページの次回アクセス時にページが再生成されます。

3により、他の記事ページも次回アクセス時に再生成されます。
ですが、記事データのキャッシュが無効化されていないので、記事データのfetchはキャッシュデータを返します。
HTMLは再生成されますが、無駄なアクセスを回避できています。

 

本番環境の構築

重要:ビルドとキャッシュ

Next.jsはアプリケーションのビルド時に静的htmlを出力します。
このとき、キャッシュが有効ならキャッシュを利用します。

今回のようなSSG構成ではキャッシュが常に有効なため、fetchが外部のAPIにアクセスしません。
結果として、静的htmlが更新されたとしても同じ内容になります。

全ての静的htmlを再構築する場合は、Next.jsが生成した .next ディレクトリを作成してからビルドを実行してください。

サーバーでビルド

ビルド時にデータ取得を行うので、基本的にはサーバーにコンフィグを含めたコードをアップロードしてサーバー側でビルドを行います。
必要に応じて、インストールを行います。

ビルドが終わったら、npm run startでnextを実行します。

npm install
npm run build
npm run start

ローカルでビルド

ローカルでビルドする場合は、ビルドで生成された.nextフォルダ一式をフォルダ毎アップロードします。
必要に応じてコンフィグファイルもアップロードしましょう。

アップロードするファイル

.next/
package.json
package-lock.json
public/
next.config.js

アップロードできたら、npm run startでnextを実行します。

Apache等で配信する

Apache等のWebサーバーで配信することも可能です。
ただし、完全な静的ファイルの配信となるため、Webhook等の動的な仕組みは動作しません。

次の手順で作業をおこないます。

1. APIを削除

コードが存在するとビルドエラーになります。

2. nextのコンフィグファイルを変更

next.sonfig.mjs

const nextConfig = {
  output: "export",
  basePath:"/subdir/test"
};

basePathはサブディレクトリに設置するときに記述する。
ルートなら不要。
basePathはLinkコンポーネント("next/link")に適用されます。
aタグは適用されないので注意しましょう。

3. ビルド実行

npm run build

4. 生成されたoutフォルダーの中身をサーバーにアップロード

.txt等が出力されるが、スクリプトで読み込まれるためそのままアップロードする。

5. ブラウザでアップロード先を開いて動作確認

 

最後に

Next.jsを使うと、とても簡単に静的サイトが作成できます。
JSXを使うと煩雑なHTML文字列操作から解放されるのが良いです。
フォルダ配置がそのままURLのパスになるのはapacheライクで分かりやすいですね。

とはいえ、クライアント側でよくわからないスクリプトが動いているのが気持ち悪いという人もいるはず。
まずは簡単なコードで動作確認をして、開発を進めるかどうか検討するとよいですね。

更新日:2026/03/09

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。