PR

【Next.jsブログ構築】Next.jsブログ全ページにサイドバーと人気記事ランキングを実装する方法

Next.jsブログにサイドバーと人気記事ランキングを実装する手順を解説するアイキャッチ画像。ノートパソコンの画面にブログのサイドバーランキングが表示され、女性がポイントを指差している柔らかいタッチのイラスト。白背景で清潔感のある雰囲気。 VPS・RentalServer
この記事は約28分で読めます。
記事内に広告が含まれています。
スポンサーリンク

これまでの Next.js ブログサイト構築シリーズをまとめたページは以下の通りです。

前回の第19回では、
記事が開かれるたびにページごとの閲覧数を
SQLite データベースに記録する仕組みを実装しました。

閲覧数のデータを記録できるようにした理由は何でしょうか?

もちろん最大の目的は、
「どの記事がどれぐらい読まれているか?」
を確認するためです。

どの記事が多くの方の関心を集めているのかを知ることで、
その関心に沿って、さらに内容を深くしたり、広げた内容の記事を書いていけば、
より多くの方に関心を持っていただけるサイトになっていくことが期待できます。

そして、この仕組みを導入したもう一つの目的は、
「どの記事が多く読まれているかを読者に伝える」
というものです。

サイトの訪問は、検索エンジンで何かしらのキーワード検索を行った結果として、
このサイトのどこかのページにたどり着くというのがほとんどなのですが、
せっかく来ていただいた方に、

「このサイトには、他にもこんなページがありますよ」
「このサイトでは、こんなページが多く読まれていますよ」

ということを伝えることができれば、
サイトの他のページも見ていただけるチャンスが増えるのではないかと考えたのです。

そこで今回は、
全ページにサイドバーを追加して「人気記事ランキング」を表示する機能を
実装することにしました。

この記事では以下の内容を解説します。

  • サイドバーの設計で何を決めたか(件数・デザイン・表示ページ)
  • ランキングデータを取得する関数と API の作成方法
  • サイドバーコンポーネントの作成と全ページへの組み込み方法
  • 実装後に503エラーが発生した原因と修正方法
  • 第18回の Nginx 設定に潜在していたバグの解説
  1. 記事の前提条件
  2. サイドバーの設計を決める
    1. 何件表示するか:5件
    2. どのデザインにするか:順位番号+タイトルのみ
    3. どのページに表示するか:全ページ
    4. ランキングの内容:ページの種類によって変える
    5. サイドバーを表示する画面幅:1024px 以上
  3. 今回作成・変更するファイル
  4. STEP 1:ランキング取得関数を作成する(lib/ranking.ts)
    1. この関数が必要な理由
    2. 重要な設計上の工夫
    3. 作成コマンド
    4. コードの重要ポイント解説
  5. STEP 2:ランキング API を作成する(app/api/ranking/route.ts)
    1. なぜ API が必要なのか
    2. 作成コマンド
  6. STEP 3:サイドバーコンポーネントを作成する(components/Sidebar.tsx)
    1. 'use client' が必要な理由
    2. ローディング中の「スケルトン表示」
    3. 作成コマンド
  7. STEP 4:各ページにサイドバーを組み込む
    1. レイアウト変更の共通パターン
    2. Tailwind CSS の主要クラスの意味
    3. カテゴリー・タグページのサイドバー配置
    4. ページごとのファイル変更
  8. STEP 5:ビルドしてデプロイする
  9. 503エラーの発生と根本原因の調査
    1. 調査:Next.js は正常、Nginx に問題あり
    2. 根本原因①:第18回の Nginx 設定のバグ
    3. 根本原因②:レート制限ゾーンの設計バグ
    4. 根本原因③:動的画像最適化のメモリ圧迫
  10. 503エラーの修正
    1. 修正①:Nginx の Connection ヘッダー設定
    2. 修正②:レート制限ゾーンの削除
    3. 修正③:next.config.ts の変更
    4. 修正後のビルドとデプロイ
    5. Nginx 設定のテストとリロード
  11. 第18回記事・作業ノートへの追記
  12. まとめ

記事の前提条件

この記事は以下の環境を前提として手順を解説しています。

  • 第19回まで完了していること: 閲覧数カウンターが動作し、total_views テーブルにデータが記録されている状態
  • Next.js のバージョン: Next.js 16.2 系(React 19)環境の App Router を使用
  • ビルド方式: サーバーモード(next start + PM2)で動作
  • 対象読者: シリーズ第19回まで完了している方

サイドバーの設計を決める

実装を始める前に、どんなサイドバーにするかを整理しました。
設計をきちんと決めておくと、実装がスムーズに進みます。

何件表示するか:5件

10件まで表示することも検討しましたが、将来的にサイドバーにはランキング以外のコンテンツ(アフィリエイトリンクや記事まとめへのリンクなど)も追加する予定があります。
10件表示するとランキングだけでサイドバーの大半を占めてしまうため、5件に絞りました。

どのデザインにするか:順位番号+タイトルのみ

3つのデザイン案を比較しました。

デザイン案特徴
順位番号+タイトルのみシンプルで省スペース
順位番号+サムネイル+タイトル視覚的だが高さがある
フルカード(画像大)最も目立つが場所を取りすぎる

将来の他コンテンツとの共存を考えると、コンパクトさが最優先です。
「順位番号+タイトルのみ」を採用しました。

どのページに表示するか:全ページ

トップページと個別記事ページだけでなく、カテゴリーページ・タグページにも表示します。
サイドバーは読者の回遊を促す機能なので、あらゆるページで活用しない手はありません。

ランキングの内容:ページの種類によって変える

ここが少しこだわりのポイントです。
全ページで同じランキングを表示するのではなく、ページの種類に合わせてランキングを絞り込むことにしました。

ページ表示するランキング
トップページ(1ページ目・2ページ目以降)全記事の全期間 Top5
個別記事ページ全記事の全期間 Top5
カテゴリーページそのカテゴリー内 Top5
タグページそのタグ内 Top5

カテゴリーページで「Next.js」カテゴリーを見ている読者に、「Next.js の中でよく読まれている記事」を案内できれば、より関連性の高い記事へ誘導できます。

なお、カテゴリーやタグの記事が3件未満の場合は、絞り込んでも意味のあるランキングにならないため、全体ランキングで代替します。

サイドバーを表示する画面幅:1024px 以上

スマートフォンなど画面が狭い端末では、サイドバーは表示しません。
1024px 以上の画面(主にパソコン・タブレット)でのみ表示します。

768px 以上でサイドバーを表示しようとすると、メインコンテンツの記事カードが極端に狭くなって読みにくくなってしまいます。
1024px 以上という設定がちょうどいいバランスでした。

今回作成・変更するファイル

操作ファイル内容
新規作成lib/ranking.tsランキングデータを取得する関数
新規作成app/api/ranking/route.tsランキングを返す API エンドポイント
新規作成components/Sidebar.tsxサイドバーの UI コンポーネント
変更app/page.tsxトップページ1ページ目
変更app/page/[pageNum]/page.tsxトップページ2ページ目以降
変更app/posts/[id]/page.tsx個別記事ページ
変更app/category/[slug]/page.tsxカテゴリーページ
変更app/tags/[slug]/page.tsxタグページ

STEP 1:ランキング取得関数を作成する(lib/ranking.ts)

この関数が必要な理由

total_views テーブルには url(記事の URL)と count(閲覧数)の2つの値しか入っていません。
サイドバーには記事のタイトルも表示する必要があるため、SQLite のデータと Markdown の記事データを組み合わせる処理が必要です。

total_views テーブル           Markdown の記事データ
/posts/nextjs-pm2 | 3    ←→  タイトル:「next startだけで...」
/posts/claude-mcp | 2    ←→  タイトル:「ClaudeデスクトップとObsidianを...」

この「組み合わせ」をする関数を lib/ranking.ts に作ります。

重要な設計上の工夫

孤立 URL の除外: total_views に記録されているURLに対応する記事が削除・リネームされている場合、タイトルが取得できずエラーになります。
Markdown にマッチしない URL は自動的にスキップする処理を入れています。

データベース接続のクローズ: try / finally 構文を使って、エラーが発生した場合でも必ずデータベース接続をクローズします。
finally ブロックは正常終了・エラー終了どちらの場合も必ず実行されるため、接続が開きっぱなしになるリスクがありません。

フォールバック(代替)処理: カテゴリー・タグで絞り込んだ結果が3件未満のとき、全体ランキングで代替して isGlobalFallback: true というフラグを返します。
このフラグを使って、サイドバーのタイトルを「このカテゴリーの人気記事」ではなく「人気記事 Top 5」に切り替えます。

作成コマンド

cat > ~/example-blog/lib/ranking.ts << 'EOF'
import { getDb } from './db';
import { getSortedPostsData } from './posts';

// ランキング1件分のデータ型
export type RankedPost = {
  url: string;    // 記事のURL(例:/posts/nextjs-setup)
  title: string;  // 記事タイトル
  count: number;  // 全期間の閲覧数
};

// カテゴリー・タグランキングの返り値型
// isGlobalFallback: 対象記事が3件未満で全体ランキングで代替した場合に true になる
export type FilteredRankingResult = {
  ranking: RankedPost[];
  isGlobalFallback: boolean;
};

// 内部共通処理:SQLite からランキングを組み立てる
// allowedUrls が null のときは全記事が対象
function buildRanking(allowedUrls: Set<string> | null, limit: number): RankedPost[] {
  const db = getDb();
  try {
    const rows = db.prepare(
      'SELECT url, count FROM total_views ORDER BY count DESC'
    ).all() as { url: string; count: number }[];

    const allPosts = getSortedPostsData();
    const postTitleMap = new Map(
      allPosts.map((p) => [`/posts/${p.id}`, p.title])
    );

    const result: RankedPost[] = [];

    for (const row of rows) {
      if (allowedUrls !== null && !allowedUrls.has(row.url)) continue;
      const title = postTitleMap.get(row.url);
      if (!title) continue;  // 対応する記事が存在しない場合はスキップ
      result.push({ url: row.url, title, count: row.count });
      if (result.length >= limit) break;
    }

    return result;
  } finally {
    db.close();  // エラーが起きても必ずDBをクローズ
  }
}

// 全記事の全期間ランキングを取得する
export function getGlobalRanking(limit = 5): RankedPost[] {
  return buildRanking(null, limit);
}

// 指定カテゴリー内の全期間ランキングを取得する
// 3件未満の場合は全体ランキングで代替し isGlobalFallback: true を返す
export function getCategoryRanking(category: string, limit = 5): FilteredRankingResult {
  const allPosts = getSortedPostsData();
  const urls = new Set(
    allPosts
      .filter((p) => p.category === category)
      .map((p) => `/posts/${p.id}`)
  );
  const ranked = buildRanking(urls, limit);
  if (ranked.length >= 3) {
    return { ranking: ranked, isGlobalFallback: false };
  }
  return { ranking: buildRanking(null, limit), isGlobalFallback: true };
}

// 指定タグを持つ記事の全期間ランキングを取得する
// 3件未満の場合は全体ランキングで代替し isGlobalFallback: true を返す
export function getTagRanking(tag: string, limit = 5): FilteredRankingResult {
  const allPosts = getSortedPostsData();
  const urls = new Set(
    allPosts
      .filter((p) => p.tags.includes(tag))
      .map((p) => `/posts/${p.id}`)
  );
  const ranked = buildRanking(urls, limit);
  if (ranked.length >= 3) {
    return { ranking: ranked, isGlobalFallback: false };
  }
  return { ranking: buildRanking(null, limit), isGlobalFallback: true };
}
EOF

コードの重要ポイント解説

Map を使ったURL→タイトルの引き当て

Map は「キー→値」の対応表を作るデータ構造です。
全記事を URL とタイトルのペアで登録しておき、後から postTitleMap.get(url) とするだけで URL に対応するタイトルを即座に引き出せます。

Set を使った絞り込み

Set は重複を許さないデータの集合です。
カテゴリーが一致する記事の URL を Set に入れておき、allowedUrls.has(url) でそのURLがセットに含まれているかを高速に判定します。

STEP 2:ランキング API を作成する(app/api/ranking/route.ts)

なぜ API が必要なのか

サイドバーはページが表示された後にブラウザ側でランキングを取得して表示します(クライアントサイドフェッチ)。
ブラウザは直接 SQLite を読み込むことができないため、Next.js の API エンドポイントを経由してデータを受け取る必要があります。

URL にクエリパラメーターを付けることで、全体ランキング・カテゴリー別・タグ別を1つの API で切り替えます。

/api/ranking               → 全体ランキング
/api/ranking?type=category&value=nextjs  → nextjs カテゴリーのランキング
/api/ranking?type=tag&value=sqlite       → sqlite タグのランキング

作成コマンド

mkdir -p ~/example-blog/app/api/ranking
cat > ~/example-blog/app/api/ranking/route.ts << 'EOF'
import { NextRequest, NextResponse } from 'next/server';
import { getGlobalRanking, getCategoryRanking, getTagRanking } from '@/lib/ranking';

// リクエストごとにサーバーで処理する(request.url を使うため必須)
export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const type  = searchParams.get('type')  ?? '';
    const value = searchParams.get('value') ?? '';

    // バリデーション:value が長すぎる場合は弾く
    if (value.length > 100) {
      return NextResponse.json({ error: 'invalid parameter' }, { status: 400 });
    }

    if (type === 'category' && value) {
      const { ranking, isGlobalFallback } = getCategoryRanking(value);
      return NextResponse.json({ ranking, isGlobalFallback });
    }

    if (type === 'tag' && value) {
      const { ranking, isGlobalFallback } = getTagRanking(value);
      return NextResponse.json({ ranking, isGlobalFallback });
    }

    const ranking = getGlobalRanking();
    return NextResponse.json({ ranking, isGlobalFallback: false });

  } catch (error) {
    console.error('[api/ranking] error:', error);
    return NextResponse.json({ error: 'internal server error' }, { status: 500 });
  }
}
EOF

export const dynamic = 'force-dynamic' とは

この1行を書くことで、Next.js はこの API を「毎回サーバーで処理する」モードにします。
これを書かないと、ビルド時に Next.js が「静的にプリレンダリングしようとして失敗する」という問題が起きます。
request.url を使ってクエリパラメーターを読み取る場合は必ず書く必要があります。

STEP 3:サイドバーコンポーネントを作成する(components/Sidebar.tsx)

'use client' が必要な理由

ページが表示された後にブラウザ側でデータを取得するには、React の useEffect(副作用処理)と useState(状態管理)を使います。
これらはクライアントコンポーネントでしか使えないため、ファイルの先頭に 'use client' を書く必要があります。

ローディング中の「スケルトン表示」

データの取得には少し時間がかかります。
取得完了まで何も表示しないと、画面がいきなり切り替わって見た目が不自然です。
そこで、取得中はグレーのバーを表示するスケルトン(骨格)アニメーションを実装しました。
animate-pulse という Tailwind CSS のクラスがゆっくり点滅させてくれます。

作成コマンド

cat > ~/example-blog/components/Sidebar.tsx << 'EOF'
'use client';

import { useEffect, useState } from 'react';
import Link from 'next/link';
import type { RankedPost } from '@/lib/ranking';

type Props = {
  filterType?: 'category' | 'tag';  // 絞り込みの種類(省略時は全体ランキング)
  filterValue?: string;              // 絞り込みの値(例:'nextjs' / 'sqlite')
};

export default function Sidebar({ filterType, filterValue }: Props) {
  const [ranking, setRanking] = useState<RankedPost[]>([]);
  const [loading, setLoading] = useState(true);
  const [isGlobalFallback, setIsGlobalFallback] = useState(false);

  useEffect(() => {
    let url = '/api/ranking';
    if (filterType && filterValue) {
      url += `?type=${encodeURIComponent(filterType)}&value=${encodeURIComponent(filterValue)}`;
    }

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error('fetch failed');
        return res.json();
      })
      .then((data) => {
        setRanking(data.ranking ?? []);
        setIsGlobalFallback(data.isGlobalFallback ?? false);
      })
      .catch(() => {
        setRanking([]);
        setIsGlobalFallback(false);
      })
      .finally(() => {
        setLoading(false);
      });
  }, [filterType, filterValue]);

  return (
    <aside className="w-full">
      <div className="bg-[#2a2d38] border border-[#3a3f4a] rounded-lg p-4">

        {/* タイトル:状況に応じて切り替える */}
        <h2 className="text-sm font-bold text-[#9ca3af] pb-2 mb-3 border-b border-[#3a3f4a]">
          {isGlobalFallback || !filterType
            ? '人気記事 Top 5'
            : filterType === 'category'
            ? 'このカテゴリーの人気記事'
            : 'このタグの人気記事'}
        </h2>

        {/* ローディング中:スケルトン表示 */}
        {loading && (
          <div className="space-y-3">
            {[...Array(5)].map((_, i) => (
              <div key={i} className="flex items-start gap-2 animate-pulse">
                <span className="text-[#0f6e56] font-bold text-base w-4 shrink-0">{i + 1}</span>
                <div className="h-4 bg-[#3a3f4a] rounded w-full" />
              </div>
            ))}
          </div>
        )}

        {/* データなし */}
        {!loading && ranking.length === 0 && (
          <p className="text-xs text-[#6b7280]">まだデータがありません。</p>
        )}

        {/* ランキングリスト */}
        {!loading && ranking.length > 0 && (
          <ol className="space-y-3">
            {ranking.map((post, index) => (
              <li key={post.url} className="flex items-start gap-2">
                <span className="text-[#0f6e56] font-bold text-base w-4 shrink-0 leading-snug">
                  {index + 1}
                </span>
                <Link
                  href={post.url}
                  className="text-xs text-[#d4d8e2] leading-snug hover:text-[#4dd4b0] transition-colors line-clamp-3"
                >
                  {post.title}
                </Link>
              </li>
            ))}
          </ol>
        )}

      </div>
    </aside>
  );
}
EOF

STEP 4:各ページにサイドバーを組み込む

レイアウト変更の共通パターン

全5ページで同じ構造変更を行います。変更の要点は2つです。

① 外枠のレイアウトを1カラムから2カラムに変更する

{/* 変更前:1カラム */}
<div className="max-w-4xl mx-auto px-4 py-8">
  {/* メインコンテンツ */}
</div>

{/* 変更後:2カラム(メイン+サイドバー) */}
<div className="max-w-6xl mx-auto px-4 py-8">
  <div className="flex gap-8 items-start">

    {/* メインコンテンツ */}
    <main className="flex-1 min-w-0">
      {/* 既存の内容はそのまま */}
    </main>

    {/* サイドバー:lg(1024px)以上で表示 */}
    <div className="w-64 flex-shrink-0 hidden lg:block">
      <div className="sticky top-4">
        <Sidebar />
      </div>
    </div>

  </div>
</div>

Sidebar コンポーネントをインポートして配置する

各ページのファイルの先頭に、以下のインポートを追加します。(パスはファイルの場所によって変わります)

import Sidebar from '../components/Sidebar';

Tailwind CSS の主要クラスの意味

クラス意味
max-w-6xl最大幅を約1152px に拡大(max-w-4xl は約896px)
flex gap-8 items-start横並びで間隔32px、上端を揃える
flex-1 min-w-0メインコンテンツが残りのスペースをすべて使う
w-64 flex-shrink-0サイドバーの幅を256px に固定して縮まないようにする
hidden lg:block1024px 未満では非表示、1024px 以上で表示
sticky top-4スクロールしても追従し、画面上から16px の位置に留まる

sticky とは

sticky を設定したサイドバーは、ページをスクロールしても画面内に留まり続けます。
記事が長くても、サイドバーが一緒に画面外へ消えることなく、常に右側に見えている状態になります。

カテゴリー・タグページのサイドバー配置

カテゴリーページとタグページでは、絞り込み条件を渡します。

{/* カテゴリーページ:そのカテゴリー内ランキングを表示 */}
<Sidebar filterType="category" filterValue={slug} />

{/* タグページ:そのタグ内ランキングを表示 */}
<Sidebar filterType="tag" filterValue={slug} />

slug はそのページのカテゴリー名またはタグ名(例:nextjssqlite)です。

注意点: カテゴリーページには formatCategoryName という「スラッグを表示名に変換する関数」があります(例:nextjsNext.js)。
しかし Sidebar に渡すのは変換前のスラッグでなければなりません。変換後の Next.js を渡すと、記事が1件も見つからなくなります。

ページごとのファイル変更

各ページファイルのコマンドは同じ流れです。

cat > ~/example-blog/app/page.tsx << 'EOF'
(※内容は省略。詳細は前述の構造に従い、Sidebar のインポートと2カラムレイアウトを追加する)
EOF
LIFEWORK Blog Nextのトップページ。左側に新着記事の一覧、右側に「人気記事 Top 5」のサイドバーが配置されたレイアウト画面。

サイトのトップページの画像です。
画面右側にサイドバーが表示され、「人気ランキング Top 5」が表示されるようになりました。

STEP 5:ビルドしてデプロイする

すべてのファイルを変更したら、ビルドしてデプロイします。

cd ~/example-blog && ./deploy.sh

ビルドが成功すると、ルート一覧に /api/rankingf(Dynamic)として表示されます。
これは「毎回サーバーで動的処理をする API」として正しく認識されている状態です。

アプリケーションのルーティング(Route (app))構造を示すターミナル画面。「/」「/_not-found」「/api/ranking」「/api/views」「/category/[slug]」と、その配下のパスがツリー状に表示されている。

デプロイ結果が表示されたターミナルの画面の一部です。
/api/ranking/api/views が並んで表示されています。

503エラーの発生と根本原因の調査

デプロイ後、ブラウザで確認すると予想外の問題が発生しました。

  • トップページは表示されるが、サイドバーのランキングが「まだデータがありません」のまま
  • 個別記事ページやカテゴリーページを開くと503エラーが表示される

調査:Next.js は正常、Nginx に問題あり

まず Next.js プロセス自体に問題がないか確認しました。

Next.js に直接アクセスすると(Nginx を経由しない):

for i in {1..25}; do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/; done

結果:200 200 200 200 200... すべて正常です。

Nginx 経由でアクセスすると:

for i in {1..25}; do curl -s -o /dev/null -w "%{http_code} " https://next.example.com/; done

結果:200 200 200 200 200 200 503 503 503...

6件目から503エラーが始まります。
Next.js は正常で、Nginx にのみ問題があることが特定できました。

根本原因①:第18回の Nginx 設定のバグ

第18回で設定した Nginx の location / ブロックに以下の記述がありました。

proxy_set_header Connection 'upgrade';

これが503エラーの根本原因です。

Connection: upgrade は、本来 WebSocket 接続を確立するときだけ使うヘッダーです。
しかしこの書き方では、通常の HTTP リクエストにも常に送り続けることになります。

その結果、Nginx が Next.js へのキープアライブ接続(HTTP/1.1 の接続維持機能)を正しく管理できなくなり、6件ほどのリクエストの後に接続プールが破綻して503エラーが発生していました。

第19回まではリクエスト数が少なかったため問題が表面化しませんでしたが、今回サイドバーを追加したことで1ページあたりのリクエスト数が増え(ページ本体 + /api/ranking + /api/views)、潜在していたバグが顕在化しました。

根本原因②:レート制限ゾーンの設計バグ

nginx.conf に以下の設定がありました。

map $request_method $nextjs_post_limit_key {
    POST    $binary_remote_addr;
    default "";
}
limit_req_zone $nextjs_post_limit_key zone=nextjs_post:10m rate=10r/m;

POST リクエストだけをレート制限しようとした設計でしたが、default "" によってGET リクエストのキーが空文字列になります。
Nginx は空文字列のキーを「全ユーザー共通の1つのバケット」として扱うため、世界中のユーザーの GET リクエストが合算されてレート制限にかかる状態になっていました。

根本原因③:動的画像最適化のメモリ圧迫

第18回でサーバーモードに切り替えた際、next.config.ts から unoptimized: true を削除しました。
その結果、/_next/image による動的画像最適化が有効になりましたが、2GB の VPS では複数の画像を含むページへの同時アクセスでメモリ不足が発生していました。

503エラーの修正

修正①:Nginx の Connection ヘッダー設定

通常の HTTP と WebSocket を動的に切り替える map ディレクティブを使います。

Nginx 設定ファイルの冒頭(upstream ブロックの前)に以下を追加します。

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      "";
}

location / ブロック内を変更します。

変更前:

proxy_set_header Connection 'upgrade';

変更後:

proxy_set_header Connection $connection_upgrade;

map ディレクティブとは

$http_upgrade(クライアントから来る Upgrade ヘッダーの値)によって $connection_upgrade の値を動的に決める仕組みです。
WebSocket の接続確立リクエストのときは upgrade を、通常の HTTP リクエストのときは空文字(ヘッダーを除去)を送ります。
これにより Nginx のキープアライブ接続が正しく機能するようになります。

修正②:レート制限ゾーンの削除

nginx.conf から問題のあった nextjs_post ゾーン定義を削除します。
Server Action 攻撃への対策は Nginx のサイト設定ファイルで if ディレクティブを使う方法に切り替えました。

# Server Action 攻撃を遮断(Next-Action ヘッダーを持つリクエストをブロック)
if ($http_next_action) { return 444; }

444 は Nginx 独自のステータスコードで、接続を即座に切断します。
レスポンスを返さずに切断するため、攻撃ボットにとって情報を一切得られない遮断方法です。

修正③:next.config.ts の変更

動的画像最適化を無効化します。

cat > ~/example-blog/next.config.ts << 'EOF'
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    // Next.jsの画像最適化をオフ
    // (2GB VPSでは動的変換がメモリ不足の原因になるため)
    unoptimized: true,
  },
};

export default nextConfig;
EOF

unoptimized: true により、/_next/image による動的変換を行わなくなります。
画像は元のファイルをそのまま配信し、ブラウザ側でリサイズされます。2GB VPS での安定運用にはこの設定が不可欠でした。

修正後のビルドとデプロイ

cd ~/example-blog && ./deploy.sh

Nginx 設定のテストとリロード

sudo nginx -t && sudo systemctl reload nginx

nginx: configuration file ... test is successful と表示されれば成功です。

第18回記事・作業ノートへの追記

今回の調査で第18回の Nginx 設定に誤りがあったことが判明したため、第18回のブログ記事と Obsidian 作業ノートに「追記・修正(2026-05-02)」セクションを追加しました。

第18回の手順で構築されている方は、本記事の「503エラーの修正」セクションの内容を適用してください。

まとめ

この LIFEWORK Blog Next は、まだまだ記事も少なく、訪問者も少ないサイトですが、閲覧者数のデータを記録できる仕組みをつくり、それをランキングとして、自分のサイト内で紹介できるようになったことはとても嬉しかったです。

以前の作業で作成したコードにバグがあったため、作業直後はトップページも、個別記事やカテゴリーのページも表示がおかしくなったり、エラー表示になってしまい、解決するのには時間も手間もかかったのですが、AI を活用しながら原因を探ったことで、Nginx の仕組みをより深く理解することができました。

今回の作業をまとめます。

作業内容
lib/ranking.ts の作成SQLite と Markdown を組み合わせてランキングを組み立てる関数
app/api/ranking/route.ts の作成ランキングデータを JSON で返す API エンドポイント
components/Sidebar.tsx の作成クライアントサイドでランキングを取得・表示するコンポーネント
全5ページへのサイドバー組み込み2カラムレイアウトへの変更とサイドバーの配置
503エラーの修正Nginx の Connection ヘッダーバグ・レート制限設計バグ・メモリ圧迫の3点を解消

サイドバーの実装は計画通りに進みましたが、デプロイ後に503エラーという想定外の問題が発生しました。
調査の結果、第18回から潜在していた Nginx 設定のバグが今回のリクエスト数増加によって表面化したことがわかりました。

問題の発見と修正は大変でしたが、Nginx のキープアライブ接続の仕組みについて深く理解できたことは収穫でした。
非エンジニアとしてサーバーを運用していると、こういう出来事が学びのきっかけになります。

次の回では、引き続きシリーズのロードマップに沿って機能の拡充を進めていきます。

コメント

タイトルとURLをコピーしました