Sync
Handbook
【第1回】環境構築とプロジェクト設計【第4回】DB設計とAlembicマイグレーション
【第2回】htmx でSPA風ダッシュボードを作る【第3回】ダークテーマUIとコンポーネント設計
【第5回】証券APIとの接続npm install さようなら — FastAPI + htmx でトレーディングシステムを作るnpm install さようなら — FastAPI + htmx でトレーディングシステムを作る
npm install さようなら — FastAPI + htmx でトレーディングシステムを作る
Zennライクな記事プラットフォームを自作するZennライクな記事プラットフォームを自作するClaude Code「スキル」と「エージェント」徹底解説
Google GeminiとImagen 4の全貌
OpenAI / ChatGPTAI & 機械学習アイデア & ノート

その他

Zennライクな記事プラットフォームを自作するGemini画像生成技術のレビュー

© 2026 Sync. All rights reserved.

Tech
2026/2/14

Zennライクな記事プラットフォームを自作する

Part 2: Next.js 16 + React 19 でのアプリ構築

Zennライクな記事プラットフォームを自作する

こんにちは、「Zennライクな記事プラットフォームを自作する」シリーズの Part 2 です。

前回は Sync プロジェクトの全体像をお伝えしましたが、今回はその土台となる Next.js 16 と React 19 にフォーカスします。App Router によるルーティング設計、Server Components の活用、next-intl による国際化、そして Docker デプロイのための output: "standalone" 設定まで、実際のコードを引用しながら解説していきます。

この記事で学べること

  • Next.js 16 (App Router) の採用理由と実践的な使い方
  • React 19 の Server Components をフル活用したページ設計
  • [locale] / admin / api の3層ルーティング構造の設計方法
  • next-intl による i18n 構成と middleware の実装
  • output: "standalone" が Docker デプロイに必要な理由と設定方法

目次

  1. Next.js 16 (App Router) の採用理由
  2. React 19 の新機能活用
  3. ルーティング設計: 3層構造
  4. next.config.ts の設定
  5. next-intl による i18n 構成
  6. middleware.ts の役割
  7. Docker デプロイと standalone output
  8. まとめ

1. Next.js 16 (App Router) の採用理由

Sync では Next.js 16.1.6 を採用しています。Next.js を選んだ理由は主に3つあります。

Server Components がデフォルト: App Router では、すべてのコンポーネントがデフォルトで Server Component として扱われます。これにより、データベースへの直接アクセスやサーバーサイドの処理をコンポーネント内で自然に書くことができます。記事プラットフォームのように「サーバーでデータを取得して表示する」パターンが多いアプリケーションには最適です。

API Route Handlers の統合: src/app/api/ にファイルを置くだけで API エンドポイントを作成できます。Sync では記事 CRUD、認証、Stripe Webhook など14本の API ルートがありますが、すべてを同じプロジェクト内で管理できるのは開発体験として非常に快適です。

ファイルベースルーティングの柔軟性: [locale] による動的ルーティング、layout.tsx によるネストレイアウト、loading.tsx によるストリーミングなど、App Router の規約に沿ってファイルを配置するだけで、複雑なルーティング要件にも対応できます。

2. React 19 の新機能活用

Sync では React 19.2.3 を使用しています。React 19 の最大の特徴は、Server Components と Client Components を明確に区別できることです。

Server Components の活用例

Sync のトップページ (src/app/[locale]/page.tsx) は Server Component として実装されています。データベースから直接記事を取得し、HTML をレンダリングしてクライアントに返します。

// src/app/[locale]/page.tsx
import { db } from "@/db";
import { articles, series, users, likes } from "@/db/schema";
import { desc, eq, asc, count } from "drizzle-orm";
import { getTranslations } from "next-intl/server";

export default async function HomePage() {
  const t = await getTranslations("home");

  // Server Component なので、直接 DB にアクセスできる
  const publishedArticlesRaw = await db
    .select({
      article: articles,
      authorName: users.name,
      authorImage: users.image,
    })
    .from(articles)
    .leftJoin(users, eq(articles.authorId, users.id))
    .where(eq(articles.status, "published"))
    .orderBy(desc(articles.publishedAt));

  // いいね数の集計も Server Component 内で完結
  const likeCounts = await db
    .select({
      articleId: likes.articleId,
      count: count(),
    })
    .from(likes)
    .groupBy(likes.articleId);

  // ...レンダリング
}

ここで注目すべきポイントは、async function でページコンポーネントを定義していることです。React 19 の Server Components では、コンポーネント自体を async にしてデータフェッチを直接記述できます。useEffect + useState でローディング状態を管理する必要がなくなり、コードがシンプルになります。

Client Components との使い分け

一方で、ユーザーのインタラクションが必要なコンポーネントは "use client" ディレクティブを付けて Client Component として実装します。

// src/app/providers.tsx
"use client";

import { SessionProvider } from "next-auth/react";
import { Toaster } from "@/components/ui/sonner";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SessionProvider>
      {children}
      <Toaster />
    </SessionProvider>
  );
}

Sync における Server / Client Components の使い分け方針は以下のとおりです。

コンポーネント種別 判断基準 例
Server Component DB アクセス、翻訳取得、認証チェック ページ (page.tsx)、レイアウト (layout.tsx)
Client Component ユーザー操作、ブラウザ API、状態管理 エディタ、検索ダイアログ、いいねボタン

3. ルーティング設計: 3層構造

Sync のルーティングは、大きく 3つのレイヤー に分かれています。

src/app/
├── [locale]/     # レイヤー1: 公開ページ (i18n 対応)
├── admin/        # レイヤー2: 管理画面 (i18n スキップ)
└── api/          # レイヤー3: API エンドポイント

レイヤー1: [locale] - 公開ページ

一般ユーザー向けのページはすべて [locale] ディレクトリ配下に配置します。next-intl が URL のロケール部分を自動的にパースし、適切な言語の翻訳データを提供します。

[locale]/
├── page.tsx                 # トップページ (/)
├── articles/[slug]/page.tsx # 記事詳細 (/articles/my-article)
├── series/[slug]/page.tsx   # シリーズ詳細 (/series/my-series)
├── auth/signin/page.tsx     # サインイン (/auth/signin)
├── profile/page.tsx         # プロフィール (/profile)
└── checkout/success/page.tsx # 購入完了 (/checkout/success)

[locale]/layout.tsx は公開ページ共通のレイアウトを提供します。ヘッダー、サイドバー、フッター、検索ダイアログを含むレイアウトで、記事一覧やシリーズ一覧のデータもここで取得してサイドバーに渡しています。

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { ArticleSidebar } from "@/components/layout/article-sidebar";
import { SearchDialog } from "@/components/search/search-dialog";
import { db } from "@/db";
import { articles, series } from "@/db/schema";

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) {
    notFound();
  }

  const messages = (await import(`../../../messages/${locale}.json`)).default;

  // サイドバー用のデータを Server Component で取得
  const allArticles = await db
    .select({
      slug: articles.slug,
      title: articles.title,
      emoji: articles.emoji,
      articleType: articles.articleType,
      seriesId: articles.seriesId,
      seriesOrder: articles.seriesOrder,
    })
    .from(articles)
    .where(eq(articles.status, "published"))
    .orderBy(desc(articles.publishedAt));

  return (
    <NextIntlClientProvider locale={locale} messages={messages}>
      <div className="flex min-h-screen flex-col">
        <Header articles={allArticles} seriesList={allSeries} />
        <div className="flex flex-1">
          <ArticleSidebar articles={allArticles} seriesList={allSeries} />
          <main className="min-w-0 flex-1">{children}</main>
        </div>
        <Footer />
        <SearchDialog />
      </div>
    </NextIntlClientProvider>
  );
}

設計上のポイント: NextIntlClientProvider でレイアウト全体をラップし、子コンポーネントで useTranslations フックが使えるようにしています。翻訳メッセージはサーバーサイドで import() により動的に読み込んでいます。

レイヤー2: admin/ - 管理画面

管理画面は [locale] の外に配置しています。これにより、i18n ミドルウェアの処理をスキップし、URL もシンプルに /admin/articles のような形になります。

// src/app/admin/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function AdminLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Server Component で認証チェック
  const session = await auth();

  if (!session?.user || session.user.role !== "admin") {
    redirect("/auth/signin");
  }

  return (
    <div className="flex min-h-screen">
      <aside className="w-60 border-r bg-muted/30 p-4">
        {/* サイドバーナビゲーション */}
      </aside>
      <main className="flex-1 p-8">{children}</main>
    </div>
  );
}

auth() を呼び出して管理者権限をチェックし、権限がなければサインインページにリダイレクトします。この認証チェックが Server Component の layout.tsx で完結している のがポイントです。レイアウトレベルで認証を行うことで、配下のすべてのページが自動的に保護されます。

レイヤー3: api/ - API エンドポイント

API は Next.js の Route Handlers で実装しています。Sync には14本の API ルートがあります。

api/
├── admin/articles/          # POST: 記事作成, GET: 記事一覧
├── admin/articles/[id]/     # PUT: 記事更新, DELETE: 記事削除
├── admin/series/            # POST: シリーズ作成, GET: シリーズ一覧
├── admin/series/[id]/       # PUT: シリーズ更新, DELETE: シリーズ削除
├── articles/[id]/like/      # POST: いいねトグル
├── articles/[id]/bookmark/  # POST: ブックマークトグル
├── auth/[...nextauth]/      # NextAuth エンドポイント
├── checkout/                # POST: Stripe Checkout セッション作成
├── subscription/            # POST: サブスク開始
├── subscription/portal/     # POST: Stripe カスタマーポータル
├── stripe/webhook/          # POST: Stripe Webhook 受信
├── search/                  # GET: 記事検索
├── upload/                  # POST: 画像アップロード
├── og/                      # GET: OG 画像生成
└── health/                  # GET: ヘルスチェック

4. next.config.ts の設定

Sync の next.config.ts は非常にシンプルですが、重要な設定が2つあります。

// next.config.ts
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";

const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");

const nextConfig: NextConfig = {
  output: "standalone",
  serverExternalPackages: ["@libsql/client"],
};

export default withNextIntl(nextConfig);

output: "standalone"

この設定は、Next.js のビルド成果物を スタンドアロンモード で出力します。通常の Next.js ビルドでは node_modules 全体が必要ですが、standalone モードでは必要なファイルだけが .next/standalone/ にコピーされ、node server.js で起動できる自己完結したサーバーになります。

これが Docker デプロイにおいて決定的に重要 です。理由は以下の3つです。

  1. イメージサイズの大幅削減: node_modules をコンテナにコピーする必要がなくなり、Docker イメージが数百 MB 小さくなる
  2. ビルドの高速化: COPY するファイルが少ないため、Docker のビルドキャッシュが効きやすくなる
  3. セキュリティ: 不要な依存関係がコンテナに含まれないため、攻撃面が小さくなる

serverExternalPackages: ["@libsql/client"]

@libsql/client は Turso(リモート SQLite データベース)への接続に使うライブラリですが、ネイティブバイナリを含むため、Next.js のバンドラーでは正しく処理できません。serverExternalPackages に指定することで、このパッケージをバンドルから除外し、Node.js のネイティブ require で読み込むよう指示しています。

createNextIntlPlugin

next-intl のプラグインは、./src/i18n/request.ts のパスを渡して初期化します。このプラグインが、next-intl のサーバーサイド機能(getTranslations など)を Next.js に統合します。

5. next-intl による i18n 構成

Sync では現在日本語のみをサポートしていますが、next-intl を導入することで将来的な多言語対応の土台を作っています。i18n 関連のファイルは src/i18n/ にまとめています。

ルーティング定義

// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
  locales: ["ja"],           // サポートするロケール
  defaultLocale: "ja",       // デフォルトロケール
  localePrefix: "as-needed", // デフォルトロケールの場合は URL にプレフィックスを付けない
  localeDetection: false,    // ブラウザのロケール自動検出を無効化
});

localePrefix: "as-needed" がポイントです。この設定により、日本語(デフォルト)の場合は /articles/my-article のようにプレフィックスなしの URL になります。もし将来 "en" を追加した場合は /en/articles/my-article のように英語版だけプレフィックスが付きます。

リクエスト設定

// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;

  // ロケールが不正な場合はデフォルトにフォールバック
  if (!locale || !routing.locales.includes(locale as "ja")) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    // 翻訳メッセージを動的インポート
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});

ナビゲーションヘルパー

// src/i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";

// next-intl 対応の Link, redirect, usePathname, useRouter を生成
export const { Link, redirect, usePathname, useRouter } =
  createNavigation(routing);

createNavigation が生成する Link コンポーネントを使うことで、ロケール対応のリンクが自動的に生成されます。

翻訳メッセージ

翻訳データは messages/ja.json で管理しています。ネストされたキー構造で、セクションごとにグループ化しています。

{
  "home": {
    "heroTagline": "技術とアイデアの知識ベース",
    "heroSearch": "記事を検索...",
    "latestArticles": "最新の記事",
    "browseSeries": "シリーズで探す"
  },
  "article": {
    "free": "無料",
    "paid": "有料",
    "subscription": "サブスク限定",
    "paywallTitle": "この先は{type}コンテンツです",
    "paywallPaidDesc": "この記事を¥{price}で購入すると、全文をお読みいただけます。",
    "purchaseButton": "¥{price}で購入する"
  }
}

{price} や {type} のようなプレースホルダーも next-intl が自動的に処理してくれます。

6. middleware.ts の役割

Next.js の middleware は、リクエストがページやAPIに到達する前に実行される処理です。Sync の middleware は、i18n ルーティングを処理しつつ、API と管理画面を除外するという役割を担っています。

// src/middleware.ts
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
import { NextResponse, type NextRequest } from "next/server";

const intlMiddleware = createIntlMiddleware(routing);

export default function middleware(req: NextRequest) {
  // API ルートと管理画面は i18n ミドルウェアをスキップ
  if (
    req.nextUrl.pathname.startsWith("/api") ||
    req.nextUrl.pathname.startsWith("/admin")
  ) {
    return NextResponse.next();
  }

  return intlMiddleware(req);
}

export const config = {
  // 静的ファイルやアップロード画像を除外するマッチャー
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|uploads|thumbnails|icons).*)"
  ],
};

この middleware の設計で重要なのは以下の2点です。

API と管理画面の除外: /api と /admin で始まるパスは NextResponse.next() でそのまま通過させます。API エンドポイントにロケールプレフィックスが付くのは困りますし、管理画面も固定言語で十分です。

matcher による静的ファイルの除外: _next/static、画像ファイル、favicon などのリクエストは middleware 自体を実行しません。これにより、不要な処理を避けてパフォーマンスを維持しています。

7. Docker デプロイと standalone output

最後に、output: "standalone" が Docker デプロイにどのように影響するかを見ていきましょう。

Sync の Dockerfile はマルチステージビルドを採用しています。

# ステージ1: 依存関係のインストール
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# ステージ2: アプリケーションのビルド
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build

# ステージ3: プロダクション実行環境
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# standalone output のコピー (node_modules 不要!)
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

standalone モードのおかげで、runner ステージでは以下の3つだけをコピーすれば動作します。

  1. .next/standalone/: サーバーコードと最小限の依存関係
  2. .next/static/: クライアントサイドの静的ファイル(JS、CSS など)
  3. public/: 静的アセット

node_modules をコピーする必要がないため、最終的な Docker イメージは非常に軽量になります。

本番環境では、この Docker コンテナの前段に Nginx をリバースプロキシとして配置し、静的ファイルの配信とプロキシを行っています。

# docker-compose.yml
services:
  app:
    image: ghcr.io/cws202207/sync:latest
    container_name: sync-app
    restart: unless-stopped
    env_file:
      - .env.production
    volumes:
      - uploads_data:/app/public/uploads
    expose:
      - "3000"

  nginx:
    image: nginx:1.27-alpine
    container_name: sync-nginx
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - uploads_data:/var/www/uploads:ro
    depends_on:
      app:
        condition: service_healthy

8. まとめ

以上、Next.js 16 と React 19 を使った Sync のアプリケーション構築についてお伝えしました。ポイントをまとめると以下のようになります。

  • Next.js 16 の App Router により、Server Components / Route Handlers / ファイルベースルーティングを統一的に扱える
  • React 19 の Server Components で、ページやレイアウトから直接 DB にアクセスし、async/await でデータ取得できる
  • 3層ルーティング構造 ([locale] / admin / api) で、公開ページ・管理画面・API を明確に分離
  • next.config.ts の output: "standalone" と serverExternalPackages の2設定が、Docker デプロイとネイティブパッケージ対応の要
  • next-intl の localePrefix: "as-needed" で、デフォルトロケールではクリーンな URL を実現
  • middleware.ts で i18n ルーティングを処理しつつ、API と管理画面を除外
  • standalone output により、node_modules 不要の軽量 Docker イメージを実現

次回の Part 3 では、Drizzle ORM と Turso によるデータベース設計について解説します。SQLite をローカル開発と本番で使い分ける構成や、9テーブルのスキーマ設計の詳細をお伝えしますので、お楽しみに。

最後まで読んでくださり、ありがとうございました。