Next.js でリッチテキストエディターから画像をアップロードする

Next.js の App Router プロジェクトで RichTextEditor の画像アップロードを扱うには、2 つのルートハンドラーが必要です。ファイルを受け取るものと、それを配信するものです。静的サイトのように、アップロードされたファイルを public/ に書き込むだけでは済みません。このページでは両方のハンドラーを紹介し、Node.js ランタイムで何が変わるか、そしてファイルを代わりに uploads/ に置く理由を説明します。
ブラウザー側
エディターは、ドロップ、貼り付け、または選択された画像を window.rte_file_upload_handler に渡します。ギャラリーダイアログからアップロードされた画像は window.richTextBoxUploadFile に渡され、こちらには開いているフォルダーも渡されます。共通の rte-files.js がこの両方を定義しています。ファイルを /rte/upload に POST し、返された URL をエディターに渡します。ページはこれをエディターのスクリプトと一緒に読み込み(クライアントコンポーネントについてはギャラリーのガイドを参照してください)、サーバーコンポーネントからエディターのオプションを設定します。
// The editor and rte-files.js are plain browser scripts, served from public/
// (copied there by scripts/copy-editor.mjs).
const scripts = [
"/richtexteditor/rte.js",
"/richtexteditor/plugins/all_plugins.js",
"/rte-files.js",
];
const config = {
toolbar: "full",
galleryEndpoint: "/rte/gallery",
maxUploadFileSize: 4000000,
};アップロード用のルートハンドラー
// POST /rte/upload file, folder -> { url }
import { GalleryError, checkOrigin, errorResponse, readForm, saveImage } from "@/lib/gallery";
import { rootFor } from "@/lib/root-for";
export const runtime = "nodejs"; // the default, written out: this route uses node:fs
export async function POST(request: Request) {
try {
checkOrigin(request);
const form = await readForm(request);
const file = form.get("file");
if (!(file instanceof File)) throw new GalleryError(400, "No file was uploaded.");
const bytes = Buffer.from(await file.arrayBuffer());
const url = await saveImage(await rootFor(request), form.get("folder") ?? "", file.name, bytes);
return Response.json({ url });
} catch (error) {
return errorResponse(error);
}
}この中には、Next.js に特有の点がいくつかあります。
- Node.js ランタイム。このハンドラーは
node:fsで書き込むため、Edge ランタイムで実行してはいけません。nodejsはすでに既定値ですが、要件をコードのそばに明記しておくために、ファイルでも設定しています。Next.js のドキュメントに記載されている例外が 1 つあります。Cache Components を有効にしている場合は、nodejsも含め、runtimeのエクスポートを削除してください。 formData()からは Web のFileが得られます。multer も一時ファイルもありません。file.nameはブラウザーが送信した名前で、file.arrayBuffer()がメモリ上のバイト列を保持します。名前は拡張子と、整えたベース名を得るためだけに使い、パスになることはありません。request.formData()ではなくreadForm()。当社のテストでは、ルートハンドラーで単純にrequest.formData()を使うと、50 MB のファイルが受け付けられました。readForm()はサンプルの制限で読み込みを止めます。詳しくは Next.js での画像アップロードのサイズ制限を参照してください。checkOrigin()は、ほかのサイトからの POST を拒否します。ルートハンドラーには、Server Actions にあるような Origin のチェックがないためです。Next.js での安全な画像アップロードを参照してください。
チェックと書き込みは、Next.js について何も知らない lib/gallery.ts にあります。
// Checks the name and the bytes, stores the file under a new random name, returns its URL.
export async function saveImage(root: string, folder: unknown, originalName: string, bytes: Buffer) {
const ext = extensionOf(originalName);
const matches = Object.hasOwn(SIGNATURES, ext) && SIGNATURES[ext];
if (!matches) {
throw new GalleryError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
}
if (bytes.length > MAX_BYTES) {
throw new GalleryError(413, TOO_BIG);
}
if (!matches(bytes)) {
throw new GalleryError(400, "The file content is not a valid " + ext.slice(1).toUpperCase() + " image.");
}
if (isUsersFolder(root, folder)) throw new GalleryError(404, "Folder not found.");
const dir = await resolveFolder(root, folder);
const storedName = baseNameOf(originalName) + "-" + randomBytes(4).toString("hex") + ext;
const file = path.join(dir, storedName);
await writeFile(file, bytes, { flag: "wx" }); // "wx" fails instead of overwriting
return publicUrl(file);
}保存名は、整えたベース名にランダムな 16 進数 8 文字を加えたものです。"wx" フラグにより、writeFile は上書きせずに失敗します。レスポンスは {"url":"/uploads/blog/photo-2cb4607d.jpg"} で、エディターはその URL を挿入します。
public/ を使わない理由
Next.js は public/ を静的ファイルとして配信するので、アップロードをそこに書き込むのが当然のように思えます。しかし、next build の後ではうまくいきません。next start の実行中に public/ にファイルを書き込んでリクエストしたところ、404 になりました。ビルド時にすでにあったファイルは 200 です。そのため、サンプルではプロジェクトと同じ階層の uploads/ に書き込み、キャッチオールのルートハンドラーで配信しています。
// GET /uploads/<folder>/<file> -> the stored image. Only image extensions are served,
// with nosniff so the browser never treats one as anything else.
import { readFile } from "node:fs/promises";
import { CONTENT_TYPES, errorResponse, extensionOf, resolveUpload } from "@/lib/gallery";
export const runtime = "nodejs";
export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
try {
const file = await resolveUpload((await params).path);
return new Response(await readFile(file), {
headers: {
"Content-Type": CONTENT_TYPES[extensionOf(file)],
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return errorResponse(error);
}
}// /uploads/<parts> -> the image file on disk, or a 404 GalleryError.
export async function resolveUpload(parts: string[]): Promise<string> {
const name = parts.at(-1) ?? "";
if (!isImageName(name) || /[\\/\0]/.test(name)) {
throw new GalleryError(404, "Not found.");
}
const dir = await resolveFolder(UPLOADS_DIR, parts.slice(0, -1).join("/")).catch(() => {
throw new GalleryError(404, "Not found.");
});
const file = path.join(dir, name);
const info = await lstat(file).catch(() => null);
if (!info || !info.isFile()) {
throw new GalleryError(404, "Not found.");
}
return file;
}Next.js 16 では params は Promise なので、await します。[...path] の場合、これはデコード済みのセグメントの配列です。/uploads/My%20Photos/a.jpg へのリクエストは ["My Photos", "a.jpg"] として届きます。このルートは画像の名前だけを、しかも実際のパスが uploads/ 内にあるファイルだけを配信します。その際、X-Content-Type-Options: nosniff を付けて送信します。
これには、書き込んだ内容を保持するディスクを持つサーバーが必要です。自分のマシンや VM で動かす next start がそれに当たります。永続的なディスクのないサーバーレスのホストでは、uploads/ の代わりにオブジェクトストレージが必要です。
テストする
このガイドのサンプルプロジェクトをダウンロードします。エディターのダウンロードに含まれる richtexteditor フォルダーを public/richtexteditor にコピーするか、RTE_DIR にそのフォルダーを設定します。次に npm install、npm run build、npm start を実行し、写真が入っているフォルダーで以下を実行します。
curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5102/rte/upload
{"url":"/uploads/blog/photo-2cb4607d.jpg"}
curl -I http://localhost:5102/uploads/blog/photo-2cb4607d.jpg
HTTP/1.1 200 OK
content-type: image/jpeg
x-content-type-options: nosniffhttp://localhost:5102 を開いてエディターに画像をドロップすると、画像は本文と uploads/ に追加されます。node shared/contract-test.mjs http://localhost:5102 を実行すると、35 項目のチェックがすべて実行されます。npm run dev と npm start のどちらでも合格しました。
ほかのプラットフォーム向けの同じガイド
JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms