目次

  1. はじめに
  2. Next.jsとは
  3. 初期構築
  4. 今回試してみた技術とその実装例
  5. 実際に実装した結果
  6. Next.jsの良いと感じた点
  7. 今後の展望

1. はじめに

Reactに触れ始めてから数ヶ月が経ちました。最近注目されているNext.jsを使うことで、React単体の開発と比べてどのようなメリットがあるのか試すため、簡単な生成AIチャットアプリを作成しました!このブログでは、Next.jsを学んだ内容を紹介します。

2. Next.jsとは

Next.jsはReactベースのフレームワークで、サーバーサイドレンダリングや静的サイト生成などを簡単に実現できるのが特徴です。Reactのコンポーネントベースの開発手法を継承しつつ、パフォーマンスやSEOの向上に貢献してくれます。今回はこのNext.jsを中間サーバーとして、生成AIチャットアプリのフロントエンドを開発してみました。
※今回はフロントエンドと中間サーバー部分をNext.jsで実装しており、バックエンドの処理は省略します。

詳細は公式ドキュメントをご参照ください。

3. 初期構築

Next.jsの公式ドキュメントを参考に、新しいプロジェクトを作成します。

npx create-next-app@latest

今回はNext.js 14.2を使用しており、アプリ作成時の項目は下記の通り設定しました。

What is your project named? my-chat-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like your code inside a `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to use Turbopack for `next dev`?  No / Yes
Would you like to customize the import alias (`@/*` by default)? No / Yes


*その他便利機能:VSCodeの設定でTailwind CSSの並び替えのプラグインを使用  

"eslint.useESLintClass": true,
"editor.codeActionsOnSave": {"source.fixAll.eslint": "explicit",},

参考サイト:Tailwind CSS の並び替えは eslint-plugin-tailwindcss が素敵でした。 ++ Gaji-Laboブログ  

4. 今回試してみた技術とその実装例

下記3つの技術観点でそれぞれ試してみましたので、その実装例とともにご紹介します。

<フロントエンド側>
1. App Router

Next.js App Router は、Server Components、Streaming with Suspense、 Server Actions などの React の最新機能を使用してアプリケーションを構築するための新しいモデルを導入しています。

公式ドキュメントより引用


Next.js v13より使用可能な新しいルーティング機能で、ディレクトリ構成やファイル名の定義によって、各処理を簡単に実装してくれる機能です。
例えば、各ページのルーティング定義が不要で、下記のディレクトリ構成によって、簡単にルーティング定義ができます。 

app/
  chat/
    [id]/
      page.tsx -> /chat/[id]のページが出来上がる
    page.tsx -> /chatのページが出来上がる

 

また、所定のフォルダ内に決まったファイル名の処理を記述することで簡単に意図した実装が可能でした。

app/
 chat/
  [id]/
    action.ts -> /chat/[id]ページのみで使用するServer Actionsの処理を実装できる
  layout.tsx -> chatディレクトリで共通のレイアウトを定義できる
  not-found.tsx -> 404ページが出来上がる


<Next.jsの中間サーバー側>
2. API Routes

API ルートは、Next.js を使用してパブリック API を構築するためのソリューションを提供します。
フォルダ内のファイルはすべて にマップされ、これらはサーバー側のみのバンドルであり、クライアント側のバンドル サイズを増やすことはありません。

公式ドキュメントより引用
 

app配下にapiフォルダを配置し、さらにその中にフォルダを配置することでサーバーのエンドポイントを簡単に作成できます。基本的にHTTPメソッド単位で処理を記述していくRESTful APIな仕組みでコーディングできました。

 

下記のフォルダ構成でAPIの作成ができます。

app/
  api/
    history/
        detail/
          [id]/
            route.ts -> /api/history/detail/[id]をエンドポイントとした中間サーバーのAPIが出来上がる

 

実際の実装サンプルは下記の通りです。

 

2.1. 通常時のAPI処理
・中間サーバーの処理
フォルダ構成:app/api/history/detail/[id]/route.ts

import { backendApiGet } from '@/*バックエンドAPI処理の実装先*';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

 

export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
  const { id } = params;

 

  try {
    const res = await backendApiGet({
      history_id: id,
    });

 

    // 成功時
    if (res.status === 200 && res.data) {
      return NextResponse.json(res.data,
      {
        status: 200
      });

 

      // 失敗時
     // エラーハンドリング処理の一例
     // バックエンドで定義されたエラーが4xx系の場合
    } else if (res.status >= 400 && res.status < 500) {
      return NextResponse.json(
        { error: "バックエンドから取得したエラーメッセージ" },
        { status: res.status },
      );
    }
    // その他のエラー
    // エラーハンドリング処理
    // ...
  } catch (e) {
    // エラーハンドリング処理
    // ...
  }
}


・フロントエンドからのfetch処理 

try {
  const res = await fetch(`/api/history/detail/${id}`);
  if (res.status === 200) {
    const data = await res.json();
    // エラーハンドリング処理の一例
    // バックエンドで定義されたエラーが4xx系の場合
  } else if (res.status >= 400 && res.status < 500) {
    const data = await res.json();
    // エラーハンドリング処理
    // ...
  } else {
    // エラーハンドリング処理
    // ...
  }
} catch (error) {
  // エラーハンドリング処理
  // ...
}


2.2. ストリーミング時のAPI処理
・中間サーバーの処理
フォルダ構成:app/api/question-stream/route.ts

import { backendApiPost } from '@/*バックエンドAPI処理の実装先*';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

 

// バックエンドから取得したStreamデータの加工処理
const processStream = async (
  body: { question: string },
  onStreamChunk: (question: string) => void,
  onStreamEnd: () => void,
  onError: (status: number, error: string) => void,
) => {
  try {
    const res = await backendApiPost({
      body,
    });

 

    // 成功時
    if (res.status === 200 && 'data' in res && res.data) {
      const reader = res.data.getReader();
      const decoder = new TextDecoder();

 

      // バックエンドから取得したストリーミングデータの再整形処理
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) {
            break;
          }

 

          const decodedValue = decoder.decode(value);
          const lines = decodedValue
            .split('data: ')
            .map((line) => line.trim())
            .filter((line) => line !== '');
          lines.forEach((chunk) => {
            onStreamChunk(chunk);
          });
        }
      } finally {
        reader.releaseLock();
      }
      onStreamEnd();
      return;
    }

 

    // 失敗時
    // エラーハンドリング処理の一例
    // バックエンドで定義されたエラーが4xx系の場合
    if (res.status >= 400 && res.status < 500 && 'error' in res) {
      onError(
        res.status,
        res.error?.detail
      );
      return;
    }

 

    // その他のエラー
    // エラーハンドリング処理
    // ...

 

  } catch (error) {
    // エラーハンドリング処理
    // ...
  }
};

 

export async function POST(request: NextRequest) {
  const body: { question: string } = await request.json();

 

  // Stream処理の設定
  const encoder = new TextEncoder();
  const responseStream = new TransformStream();
  const writer = responseStream.writable.getWriter();

 

  // エラー発生時に使用する変数
  let isWriterClosed = false;
  let errorStatus = 500;
  let errorMessage = '';

 

  // Stream処理中にWriterを安全にクローズするための処理
  function safeCloseWriter() {
    if (!isWriterClosed) {
      writer.close();
      isWriterClosed = true;
    }
  }

 

  // エラー発生を検知するための処理
  let errorPromiseResolve: (value: boolean | PromiseLike<boolean>) => void;
  const errorPromise = new Promise<boolean>((resolve) => {
    errorPromiseResolve = resolve;
  });
  async function handleStreamError(error: boolean) {
    errorPromiseResolve(error);
  }

 

  // Stream加工処理
  processStream(
    body as { question: string },
    (answer) => {
      writer.write(encoder.encode(`data: ${answer}\n\n`));
    },
    () => {
      safeCloseWriter();
    },
    (status, error) => {
      errorStatus = status;
      errorMessage = error;
      safeCloseWriter();
      handleStreamError(true); // エラー時の完了処理
    },
  );

 

  // エラー判定を待って、それぞれのレスポンス形式でデータを返却
  const errorOccurred = await errorPromise;

 

  // エラーの場合、通常のレスポンスを返却
  if (errorOccurred) {
    return new NextResponse(
      JSON.stringify({
        message: errorMessage,
      }),
      {
        status: errorStatus,
        headers: {
          'Content-Type': 'application/json',
        },
      },
    );
  } else {
    // 通常の場合、ストリーミングレスポンスを返却
    return new NextResponse(responseStream.readable, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
        'Content-Encoding': 'none',
      },
    });
  }
}


・フロントエンドからのfetch処理 

try {
  const res = await fetch('/api/question-stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ question: message }),
  });


  // 成功時
  if (res.status === 200) {
    const reader = res.body?.getReader();
    if (!reader) return;

 

    const decoder = new TextDecoder();

 

    // Streamデータの加工処理
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (!value) continue;

 

      const lines = decoder.decode(value);
      const dataArray = lines
        .split('\n')
        .filter((line) => line.startsWith('data: '));

 

      dataArray.forEach((dataLine) => {
        const obj = dataLine.replace('data: ', '').trim();
        const data = JSON.parse(obj);
        // データハンドリング処理
        // ...
      });
    }

 

    // 失敗時
    // エラーハンドリング処理の一例
    // バックエンドで定義されたエラーが4xx系の場合
  } else if (res?.status >= 400 && res?.status < 500) {
    const data = await res.json();
    // エラーハンドリング処理
    // ...
  }
} catch (error) {
   // エラーハンドリング処理
   // ...
}



3. Server Actions

サーバーアクションは、サーバー上で実行される非同期関数です。
これらは、サーバー コンポーネントおよびクライアント コンポーネントで呼び出して、Next.js アプリケーションでのフォームの送信とデータの変更を処理できます。

公式ドキュメントより引用
 

こちらもAPI Routesと同様に中間サーバー内でAPIを構築する際の技術となり、サーバー内で定義した関数をそのままフロントエンド内で使用できるため、早く開発したい場合に便利です。
しかし、一部の機能の実装が対応していないケースがあるため、開発する前に実現可能か検討が必要になります。

 

下記のフォルダ構成でAPIの関数を作成でき、クライアントから同じ関数名で使用できます。

app/
 chat/
  [id]/
    action.ts -> /chat/[id]ページのみで使用するServer Actionsの処理を実装できる


・中間サーバーの処理
フォルダ構成:app/${使用する画面パス}/action.ts

'use server';

 

import { backendApiGet } from '@/*バックエンドAPI処理の実装先*';

 

export async function historyDetailFetchAction(id: string) {
  try {
    const res = await backendApiGet({
      history_id: id,
    });

 

    // 成功時
    if (res.status === 200 && res.data) {
      return {
        historyDetail: res.data
      }

 

    // 失敗時
    // エラーハンドリング処理の一例
    // バックエンドで定義されたエラーが4xx系の場合
    } else if (res.status >= 400 && res.status < 500) {
      return {
        message: "バックエンドから取得したエラーメッセージ",
      };
    }


    // その他のエラー
    // エラーハンドリング処理
    // ...
  } catch (error) {
    // エラーハンドリング処理
    // ...
  }
}


・フロントエンドからのfetch処理

try {
  const res = await historyDetailFetchAction(id);
  if (res.status === 200) {
      const data = await res.json();
  } else if (res.status >= 400 && res.status < 500) {
      // エラーハンドリング処理
      // ...
} catch (error) {
      // エラーハンドリング処理
      // ...
}


※Server Actionsはストリーミング処理に対応しておらず、今回はAPI Routesで実装しました。

5. Next.jsの良いと感じた点

  • フロントエンドの処理とサーバーサイドの処理を1つのフレームワークで簡単に構築できる
  • App Routerを用いることで、フロントエンドのルーティング定義が簡単に実装できる
  • フロントエンドとサーバーサイドのルーティング処理を組み合わせて実装できる
  • サーバーサイドのAPI開発が簡単に実装できる

6. 今後の展望

今回はNext.jsを用いて簡単なアプリの実装を行ってみました。
フロントエンド側の実装自体はNext.jsを使用しない場合とほとんど差がなく、Next.jsを採用する強みを理解しきれませんでした。
そのため、今後はReactのみで実装する場合と、Next.jsと組み合わせて使用する場合の違いを実際に触ってみながら、実感しつつ理解を進めていきたいです!