Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

自作アプリではNext.js と Rails の間で、
似たような通信処理が何度も発生していました。
スクールの先生からproxyの概念を学んだので、
その仕組みを文章として整理しながら、
理解を深めたいと思います。
この構成では、Next.js のバックエンド部分が 、
BFF(Backend For Frontend) の役割を担っています。
Rails との間で認証や Cookie の処理、API 転送などを一手に引き受けることで、
コードを共有化し、シンプルにデータを扱えるようになります。
page.tsxやroute.tsの中のfetchからアクセスします。
リポジトリはこちら
https://github.com/shu915/digital_ichiba_frontend
https://github.com/shu915/digital_ichiba_backend
バージョン情報
フロントエンド
- TypeScript 5.9.2
- React 19.16
- Next.js 15.5.0
- Auth.js 5.0.0-beta.29
- TailwindCSS 4.1.12
バックエンド
- Ruby 3.3.9
- Rails 8.0.2.1
- Postgresql 16.10
- Docker 28.4.0
処理の流れ
Next.js では、API通信をプロキシ経由でRailsに転送しています。
処理の全体像は次のようになります。
1. クライアントまたはサーバーから /api/* にリクエストが送信される
2. src/app/api/[…proxy]/route.ts が HTTPメソッドごとに handleProxyRequest を呼び出す
3. getProxyFlags.ts で JWTトークンや Cookie を付与する必要があるか判定する
4. handleProxyRequest.ts で Rails API へリクエストを転送し、レスポンスを処理する
この3つのファイルを使って、Next.jsが中継(Proxy)の役割を担っています。
ルートハンドラーでAPIリクエストを処理する
ファイルはsrc/app/api/[…proxy]/route.ts
このパスの意味はURLが/api/*のように、
URLが/api/のあとに続くすべてのリクエストを受け付ける、
という意味になります。
[…proxy]というのは、キャッチオールルートと呼ばれます。
来たすべてのものをproxyとして扱い、
処理をどのようにするかはroute.tsの中に記述します。
ファイルの中身は以下のようになります。
import { NextRequest } from "next/server";
import handleProxyRequest from "@/lib/handleProxyRequest";
export const dynamic = "force-dynamic";
/*
GET, POST, PUT, DELETE, PATCH リクエストを受け取り、APIに転送する
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ proxy: string[] }> }
) {
const params = await context.params;
const proxyPaths = params.proxy;
return handleProxyRequest(request, proxyPaths, "GET");
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ proxy: string[] }> }
) {
const params = await context.params;
const proxyPaths = params.proxy;
return handleProxyRequest(request, proxyPaths, "POST");
}
export async function PUT(
request: NextRequest,
context: { params: Promise<{ proxy: string[] }> }
) {
const params = await context.params;
const proxyPaths = params.proxy;
return handleProxyRequest(request, proxyPaths, "PUT");
}
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ proxy: string[] }> }
) {
const params = await context.params;
const proxyPaths = params.proxy;
return handleProxyRequest(request, proxyPaths, "DELETE");
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ proxy: string[] }> }
) {
const params = await context.params;
const proxyPaths = params.proxy;
return handleProxyRequest(request, proxyPaths, "PATCH");
}
解説します。
import { NextRequest } from "next/server";
import handleProxyRequest from "@/lib/handleProxyRequest";
NextRequest はクライアントから、
送られてきたリクエスト情報(URL・ヘッダー・Cookie・リクエストボディなど)を、
扱うクラスです。
handleProxyRequestは、
ルートハンドラーの最後の方で呼び出して、
Railsに送る処理、帰ってきたデータの処理をします。
export const dynamic = "force-dynamic";
これを明示的に記述することで、
このファイルの中の処理では、
キャッシュをしないようにします。
export async function GET(
request: NextRequest,
context: { params: Promise<{ proxy: string[] }> }
) {
const params = await context.params;
const proxyPaths = params.proxy;
return handleProxyRequest(request, proxyPaths, "GET");
}
ここでは、GET、POST、PUT、DELETE、PATCHについて、
同じような記述をしています。
これはルーティングでそれぞれのHTTPメソッドに対応しています。
route.tsの中で記述されるこのような関数をルートハンドラーと呼びます。
同じように処理するので、GETだけを解説します。
この関数は、クライアントからリクエストが送られたときに、
Next.js が自動的に生成する NextRequest インスタンスと、
context(ルート情報などを含むオブジェクト) を引数として受け取ります。
NextRequest は Web 標準の Request を拡張したクラスで、
リクエストURL・ヘッダー・Cookie などにアクセスできます。
context は、動的ルートのパラメータなどをまとめたオブジェクトで、
たとえば […proxy] の場合は context.params.proxy として取得できます。
api/[…proxy]なので、api/の後ろのurlを/で区切ったものが、
配列に入った状態で取得できます。
example.com/api/aaa/bbb/cccならば、
[“aaa”,”bbb”,”ccc”]のという配列が取得できます
request: NextRequest,
第一引数のrequestは先ほどインポートしたNextRequestクラスのインスタンスです。
context: { params: Promise<{ proxy: string[] }> }
第二引数のcontextはオブジェクトです。
型を確認すると、キーがparamsで、
バリューがPromiseに包まれたオブジェクトです。
Promiseの中には更にオブジェクトになっており、
キーはproxyで、
バリューは文字列型の配列です。
paramsのバリューがPromiseに包まれるようになったのはNext.js 15からです。
const params = await context.params;
const proxyPaths = params.proxy;
関数の処理部の中では、
awaitで context.paramsのPromiseを解決してparamsに代入します。
さらにparams.proxyをproxyPathsに代入します。
ここは複雑なので、
省略する書き方を避けました。
return handleProxyRequest(request, proxyPaths, "GET");
handleProxyRequestに対して、
request, proxyPaths , “GET”を渡して、
実行して返したものを更に返させます。
handleProxyRequestは、ルートハンドラーの中で呼び出され、
Railsに送信する処理、帰ってきたデータの処理をします。
APIリクエストのフラグを設定する
ファイルは、lib/getProxyFlags.ts
私の作っているアプリでは、
ログインしないと見れないようなページがあります。
また、情報を更新したらCookieを更新する必要があるページがあります。
1.バックエンドに署名付きJWTを送る必要あるのか。
2.帰ってきたデータをCookieに挿入する必要があるのか。
この2つで分岐を作ります。
export default function getProxyFlags(proxyPaths: string[], method: string) {
let setJwtFlag = false;
let setCookieFlag = false;
const path = proxyPaths.join("/");
switch (true) {
case path === "shop" && method === "GET":
setJwtFlag = true;
break;
case path === "shop" && method === "POST":
setJwtFlag = true;
setCookieFlag = true;
break;
//略
}
return { setJwtFlag, setCookieFlag };
}
解説します。
export default function getProxyFlags(proxyPaths: string[], method: string) {
第一引数として、
proxyPathsを文字列の配列として受け取ります。
exmaple.com/api/aaa/bbb/cccだと、
route.tsの方で処理して、
[“aaa”, “bbb”, “ccc”]になって、渡ってきます。
第二引数のはGETやPOSTなどのHTTPメソッドを引き取ります。
let setJwtFlag = false;
let setCookieFlag = false;
初期値としてsetJwtFlagもsetCookieFlagもfalseにして、
バックエンドJWTも、Cookieもセットしません。
必要に応じてswitch文で、trueに書きまえます。
const path = proxyPaths.join("/");
proxyPathsはapi/の後ろのものをスラッシュで区切ったものなので、
それをつなぎ直して、pathとします。
switch (true) {
内部のcaseの条件がtrueならば、
そのcaseの内部の処理を実行します。
署名付きJWTもCookieも必要ない場合は、
case文を書く必要がありません。
例えば、一般ユーザーがショップを閲覧するだけの場合
JWTもCookieの挿入も発生しないので、
何も記述しません。
case path === "shop" && method === "GET":
setJwtFlag = true;
break;
GETのapi/shopはショップオーナーが、
ショップを管理するRailsのルーティングに届くので、
バックエンドJWTは必要なのでtrueにします。
break;で他のcaseに入らないようにします。
case path === "shop" && method === "POST":
setJwtFlag = true;
setCookieFlag = true;
break;
POSTの/api/shopのルーティングは、
ショップ管理人が、ショップ情報を更新したときに、
使うルーティングなので、
バックエンドJWTも必要であり、
更に帰ってきた新しいデータを、
Cookieに再挿入するので、setCookieもtrueにします。
このように必要に応じて、caseを追記していきます。
return { setJwtFlag, setCookieFlag };
最後に呼び出したところに
setJwtFlagとsetCookieFlagを返します。
trueであれば、呼び出し元で、
JWTトークンやCookieのセットする処理をします。
RailsとのAPI通信を処理する関数
ファイルは、lib/handleProxyRequest.ts
import { NextRequest, NextResponse } from "next/server";
import getProxyFlags from "@/lib/getProxyFlags";
import createBackendJwtFromRequest from "@/lib/createBackendJwtFromRequest";
export default async function handleProxyRequest(
request: NextRequest,
proxyPaths: string[],
method: string
) {
try { const { setJwtFlag, setCookieFlag } = getProxyFlags(
proxyPaths,
method
);
const endpoint = `/${proxyPaths.join("/")}`;
const url = new URL(request.url);
const apiUrl = `${process.env.RAILS_URL}/api${endpoint}${url.search}`;
const contentType = request.headers.get("content-type") || "";
const headers = new Headers();
let body;
if (["POST", "PUT", "PATCH"].includes(method)) {
if (contentType.includes("multipart/form-data")) {
body = await request.formData();
} else {
const json = await request.json().catch(() => null);
if (json != null) {
headers.set("content-type", "application/json");
body = JSON.stringify(json);
}
}
}
if (setJwtFlag) {
const jwt = await createBackendJwtFromRequest(request);
headers.set("Authorization", `Bearer ${jwt}`);
}
// APIにリクエストを転送
const response = await fetch(apiUrl, {
method,
headers,
body,
cache: "no-store",
});
// APIからのレスポンスを処理する
const railsRes = await response.json();
if (!response.ok) {
return NextResponse.json(
{ message: "データの取得に失敗しました" },
{ status: response.status }
);
}
const nextRes = NextResponse.json(railsRes, { status: response.status });
if (setCookieFlag) {
const diDataForCookie = {
user: railsRes.user,
shop: {
id: railsRes.shop.id,
name: railsRes.shop.name,
},
};
const diData = JSON.stringify(diDataForCookie);
nextRes.cookies.set("di_data", diData, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 3,
});
}
return nextRes;
} catch (error) {
console.error("API呼び出しエラー:", error);
return NextResponse.json(
{ message: "サーバーとの通信中にエラーが発生しました" },
{ status: 500 }
);
}
}
handleProxyRequestという関数は、
src/app/api/[…proxy]/route.tsの中で呼び出され、
Railsに対する送信や、帰ってきたデータの処理を実行する関数です。
解説します。
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import getProxyFlags from "@/lib/getProxyFlags";
import createBackendJwtFromRequest from "@/lib/createBackendJwtFromRequest";
NextRequestとNextResponseをnext/serverからインポートします。
NextRequestは、
ルートハンドラーで受け取ったrequestをさらに、
handleProxyRequestに引数として渡すときの型として使います。
NextResponseは、
バックエンドから帰ってきたデータを処理するもので、
リダイレクトをさせたり、
Cookieにデータを挿入したりする役割を持っています。
ルートハンドラー中で使い、
最後にNextResponseをreturnすることで、
ルーティングの処理を完了させます。
cookiesをnext/headersからインポートします。
これはNext.jsのサーバーサイドから、
ブラウザにあるクッキーを操作するのに使います。
Next.js15から非同期になったので、気をつける必要があります。
先ほど作った、getProxyFlagsもインポートしておきます。
createBackendJwtFromRequestは、
リクエストが発生したときに、バックエンド用の署名付きJWTを発行する自作関数です。
こちらで詳細に解説しています。
export default async function handleProxyRequest(
request: NextRequest,
proxyPaths: string[],
method: string
requestはルートハンドラーが引き取ったらrequestをそのまま入れます。
proxyPathsで、/api以降のパスを配列で引き取ります。
methodでHTTPメソッドを引き取ります。
try {
try-catch文を開始します。
エラーが出た場合中断せず、catchの中の処理に入るようにします。
const { setJwtFlag, setCookieFlag } = getProxyFlags(proxyPaths, method);
自作のgetProxyFlagsへ、proxyPathsとmethodを渡して、
中でcaseで分岐して、
setJwtFlagとsetCookieFlagを返してもらいます。
あとでtrueの時の処理を書きます。
const endpoint = `/${proxyPaths.join("/")}`;
const url = new URL(request.url);
const apiUrl = `${process.env.RAILS_URL}/api${endpoint}${url.search}`;
proxyPathsはapi/のあとの文字列の配列なので、
文字列に戻して、endpointに代入します。
これをやることで、Next.jsのバックエンドのルーティングに送ったものを、
そのままのパスで、Railsに送れるようになります。
new URL()を使って、request.urlからURLのインスタンスを作りurlに代入します。
urlはurl.searchとすることで、クエリパラメータを取ることができます。
process.env.RAILS_URLは、.env.localに乗っている、
RailsのURLです。
これらをつなげると、
Railsに対するfetchのURLになります。
const contentType = request.headers.get("content-type") || "";
page.tsxやroute.tsから、
Next.jsのproxyへのリクエストの、
headersからcontent-typeを取得します。
取得できない場合は空文字を入れます。
const headers = new Headers();
let body;
Next.jsのバックエンドから、
Railsのバックエンドへ渡すリクエストの
headersとbodyを定義します。
if (["POST", "PUT", "PATCH"].includes(method)) {
最初のif文で、page.tsxやroute.tsのfetchから渡ってきた、
methodの中にHTTPメソッドが入っているので、
“POST”, “PUT”, “PATCH”のリクエストならば、中の処理をするということです。
if (contentType.includes("multipart/form-data")) {
body = await request.formData();
先程のcontentTypeが”multipart/form-data”の場合
画像やファイルが添付されているので、
bodyにrequest.formData()をawaitして代入します。
注意点として、formDataのときは、
RailsにfetchしたときContent-typeは自動で挿入されるので、
自分でheaders.set()を書かないようにします。
} else {
const json = await request.json().catch(() => null);
if (json != null) {
headers.set("content-type", "application/json");
body = JSON.stringify(json);
}
dataFormではなくjsonが来た場合の処理です。
await request.json()で、jsonを取ってきて、json()でオブジェクト化します。
エラーがあった場合は、nullを返すようにします。
jsonをちゃんと取れた場合、
Railsにわたすリクエストとして
headers.set(“content-type”, “application/json”)でヘッダーをセットして、
JSON.stringify(json)でまた、文字列に戻して、bodyに代入します。
これでRailsへfetchするためのheadersとbodyができました。
if (setJwtFlag) {
const jwt = await createBackendJwtFromRequest(request);
headers.set("Authorization", `Bearer ${jwt}`);
}
ログインしないと見れないページでは、
lib/getProxyFlags.tsでsetJwtFlagをtrueにしています。
trueの場合、署名付きJWTを発行して、headersにセットします。
誰でも閲覧できるページはfalseなので、スルーします。
バックエンドJWTのロジックは、こちらで詳細に解説しています。
// APIにリクエストを転送
const response = await fetch(apiUrl, {
method,
headers,
body,
cache: "no-store",
});
Railsにfetchをします。
第一引数に組み立てたapiUrlを取ります。
第二引数はオブジェクトとして取ります。
methodはリクエストから来たものをそのまま代入、
headersとbodyは、先程作ったものを代入、
cacheはつくらせないようにします。
const railsRes = await response.json();
if (!response.ok) {
return NextResponse.json(
{ message: railsRes.message },
{ status: response.status }
);
}
Railsから帰ってきたresponseをjson()でオブジェクト化して、
railsResに代入します。
取るのに失敗した場合は呼び出し元にエラーを返すようにします。
const nextRes = NextResponse.json(railsRes, { status: response.status });
ここでNextResponseを作ります。
第一引数に、Railsから帰ってきたresponseをそのまま入れます。
第二引数に、ステータスをそのまま返します。
if (setCookieFlag) {
クッキー挿入フラグがある場合、
以下の処理をします。
const diDataForCookie = {
user: railsRes.user,
shop: {
id: railsRes.shop.id,
name: railsRes.shop.name,
},
};
railsResから一部の情報だけをCookieに挿入するのは、
すべてを入れようとするとCookieのサイズをオーバーするからです。
userの情報は全て入れます。
shopはidとnameだけを入れます。
const diData = JSON.stringify(diDataForCookie);
オブジェクトになっているのでstringifyをして、
クッキーに挿入できる形にします。
nextRes.cookies.set("di_data", diData, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 3,
});
nextResは、先程作ったNextResponseが入っているので、
cookies.setを使って、Cookieをセットします。
第一引数にキーを入れます。
第二引数にオブジェクトでいれます。
httpOnly: trueでJSから操作できないようにします。
sameSite: “lax”で、違うオリジンからPOSTなどをできないようにします。
本番環境では、httpsでないと扱えないようにします。
path: “/”とすることで、サイト全体で有効化します。
有効期限は3日です。
return nextRes;
nextResをreturnすることで、
このときにクッキーの上書きをします。
呼び出し元では、これをresponseとして受け止めます。
} catch (error) {
console.error("API呼び出しエラー:", error);
return NextResponse.json(
{ message: "サーバーとの通信中にエラーが発生しました" },
{ status: 500 }
);
}
tryの中でエラーになった場合はこちらでキャッチします
コンソールにエラーを出力します。
また呼び出し元に、
メッセージとステータスを返します。
page.tsxやroute.tsから呼びだす
サーバーコンポーネントからのfetch
const res = await fetch(`${process.env.NEXT_URL}/api/shops/${id}`, {
method: "GET",
cache: "no-store",
});
const data = await res.json();
const shop: Shop = data.shop;
このようにfetchします
Next.jsのドメインを環境変数から取ってきて、URLをつなげます。
今回はGETします。
キャッシュはしません。
これで一般ユーザーからお店を閲覧できます。
クライアントコンポーネントからのfetch
"use client"
//略
const res = await fetch("/api/shop", {
method: "POST",
cache: "no-store",
});
このようにドメインをつけない、相対パスで書きます。
まとめ
Rails側のJWT認証処理については別記事にまとめており、
また今回の通信では特別な処理も必要ないため、
本記事ではRails側のコードは割愛しました。
Next.jsからRailsにAPI通信を行う際、
認証・Cookieの制御・エラーハンドリングなど、
毎回同じような処理が必要になります。
これらを 共通化するProxyの仕組みを作ることで、
クライアント側のコードを非常にシンプルに保つことができます。
このように、フロントとバックの間にBFFを挟む設計は、
アプリ全体の保守性と可読性を高めるうえで非常に有効です。