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

Next.js のサンプルのルートハンドラーにアップロードされ、RichTextEditor に挿入された画像。上部にはすべてのボタンを表示したツールバーがある

Next.js の App Router プロジェクトで RichTextEditor の画像アップロードを扱うには、2 つのルートハンドラーが必要です。ファイルを受け取るものと、それを配信するものです。静的サイトのように、アップロードされたファイルを public/ に書き込むだけでは済みません。このページでは両方のハンドラーを紹介し、Node.js ランタイムで何が変わるか、そしてファイルを代わりに uploads/ に置く理由を説明します。

ブラウザー側

エディターは、ドロップ、貼り付け、または選択された画像を window.rte_file_upload_handler に渡します。ギャラリーダイアログからアップロードされた画像は window.richTextBoxUploadFile に渡され、こちらには開いているフォルダーも渡されます。共通の rte-files.js がこの両方を定義しています。ファイルを /rte/upload に POST し、返された URL をエディターに渡します。ページはこれをエディターのスクリプトと一緒に読み込み(クライアントコンポーネントについてはギャラリーのガイドを参照してください)、サーバーコンポーネントからエディターのオプションを設定します。

TypeScript · app/page.tsx
// 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,
};

アップロード用のルートハンドラー

TypeScript · app/rte/upload/route.ts
// 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 にあります。

TypeScript · 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/ に書き込み、キャッチオールのルートハンドラーで配信しています。

TypeScript · app/uploads/[...path]/route.ts
// 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);
  }
}
TypeScript · lib/gallery.ts
// /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 を実行し、写真が入っているフォルダーで以下を実行します。

Shell
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: nosniff

http://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

次に読む