Enviar imagens de um editor de texto rico em Next.js

Uma imagem inserida no RichTextEditor depois de ser enviada ao route handler do exemplo em Next.js, com a barra de ferramentas completa acima dela

Em um projeto Next.js com App Router, o upload de imagens do RichTextEditor precisa de dois route handlers: um que recebe o arquivo e outro que o devolve ao navegador. Você não pode simplesmente gravar os uploads em public/, como faria em um site estático. Esta página traz os dois handlers, mostra o que muda no runtime do Node.js e explica por que os arquivos vão para uploads/.

O lado do navegador

O editor entrega uma imagem arrastada, colada ou escolhida a window.rte_file_upload_handler. As imagens enviadas pela caixa de diálogo da galeria vão para window.richTextBoxUploadFile, que também recebe a pasta aberta. O rte-files.js compartilhado define os dois. Ele envia o arquivo por POST para /rte/upload e passa a URL retornada ao editor. A página o carrega junto com os scripts do editor (veja o componente de cliente no guia da galeria) e define as opções do editor a partir de um componente de servidor:

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,
};

O route handler de upload

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);
  }
}

Alguns pontos dele são específicos do Next.js:

  • O runtime do Node.js. O handler grava com node:fs, então não pode rodar no runtime Edge. nodejs já é o padrão. O arquivo o define mesmo assim, para que o requisito fique registrado junto do código. Uma exceção, segundo a documentação do Next.js: com Cache Components ativado, remova o export runtime, inclusive o de nodejs.
  • formData() entrega um File da Web. Não há multer nem arquivo temporário. file.name é o nome que o navegador enviou, e file.arrayBuffer() guarda os bytes na memória. O nome é usado apenas pela extensão e por um nome-base limpo. Ele nunca vira um caminho.
  • readForm() em vez de request.formData(). Um request.formData() simples em um route handler aceitou um arquivo de 50 MB no nosso teste. readForm() para no limite do exemplo. Limites de tamanho para upload de imagens em Next.js traz os detalhes.
  • checkOrigin() recusa POSTs vindos de outros sites, porque os route handlers não recebem a verificação de Origin que as Server Actions recebem. Veja upload seguro de imagens em Next.js.

As verificações e a gravação ficam em lib/gallery.ts, que não sabe nada sobre o Next.js:

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);
}

O nome armazenado é o nome-base limpo mais oito caracteres hexadecimais aleatórios. A flag "wx" faz writeFile falhar em vez de sobrescrever. A resposta é {"url":"/uploads/blog/photo-2cb4607d.jpg"}, e o editor insere essa URL.

Por que não public/

O Next.js serve public/ como arquivos estáticos, então gravar os uploads ali parece o caminho óbvio. Isso não funciona depois do next build. Gravamos um arquivo em public/ com o next start em execução e o requisitamos: 404. Um arquivo que já estava lá no momento do build: 200. Por isso o exemplo grava em uploads/, ao lado do projeto, e serve a pasta com um route handler catch-all:

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;
}

No Next.js 16, params é uma Promise, então ele é aguardado com await. Para [...path], é um array de segmentos já decodificados: uma requisição para /uploads/My%20Photos/a.jpg chega como ["My Photos", "a.jpg"]. A rota serve apenas nomes de imagem e apenas arquivos cujo caminho real está dentro de uploads/. Ela os envia com X-Content-Type-Options: nosniff.

Isso exige um servidor com um disco que mantenha o que você grava: next start na sua própria máquina ou VM. Um host serverless sem disco persistente precisa de armazenamento de objetos em vez de uploads/.

Teste

Baixe o projeto de exemplo deste guia. Copie a pasta richtexteditor do download do editor para public/richtexteditor, ou aponte RTE_DIR para ela. Depois execute npm install, npm run build e npm start e, a partir de uma pasta com uma foto:

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

Abra http://localhost:5102, arraste uma imagem para o editor, e ela aparece no texto e em uploads/. node shared/contract-test.mjs http://localhost:5102 executa todas as 35 verificações. Elas passaram tanto com npm run dev quanto com npm start.

O mesmo guia para outras plataformas

JavaScript · PHP · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms

Leia a seguir