Custom Endpoint
createRestAdapter uploads to your own endpoint with a plain
multipart/form-data POST — the same shape any backend already knows how to
parse, so whatever storage sits behind it (S3, disk, a database) never reaches
the browser.
import { createRestAdapter } from "../components/DropUpload"
const adapter = createRestAdapter({
endpoint: "/api/upload",
})
<DropUpload uploadAdapter={adapter} />Options
| Option | Type | Default | Notes |
|---|---|---|---|
endpoint | string | — | URL that receives the POST |
fieldName | string | "file" | Form field the file travels in |
extraFields | Record<string, string> or (file) => … | undefined | Extra form fields sent alongside the file |
getHeaders | Record<string, string> or () => … | undefined | Request headers, typically Authorization |
getUrl | (response: unknown) => string | undefined | reads url, secure_url, location, data.url | Pulls the stored file's URL out of the response body |
extraFields and getHeaders both accept a function, and both are awaited —
that's what lets a short-lived token be refreshed per upload instead of
captured once when the adapter is created, and lets a field depend on the file
being sent:
const adapter = createRestAdapter({
endpoint: "/api/upload",
fieldName: "attachment",
getHeaders: async () => ({ Authorization: `Bearer ${await getToken()}` }),
extraFields: (file) => ({ albumId, originalName: file.name }),
})Reading the response
The adapter parses the JSON body and hands the URL back to the component, so
onUploadComplete and the attached file both carry it. The defaults cover the
usual shapes; anything else is what getUrl is for:
const adapter = createRestAdapter({
endpoint: "/api/upload",
getUrl: (response) => (response as { asset: { href: string } }).asset.href,
})An endpoint that answers 200 with an empty body — or with something that
isn't JSON — has still stored the file: the upload counts as successful and
only the URL is missing.
Content-Type is deliberately not settable through getHeaders. The browser
has to write it itself so the multipart boundary matches the body.
The endpoint
Both snippets below do the same three things, and all three matter: they check
type and size, they generate the stored filename instead of trusting the one
that arrived, and they answer with JSON carrying the URL. The field name has to
match the adapter's fieldName ("file" by default).
Next.js route handler:
// app/api/upload/route.ts
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { NextResponse } from "next/server"
const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ["image/png", "image/jpeg", "application/pdf"]
export async function POST(request: Request) {
const form = await request.formData()
const file = form.get("file")
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file sent" }, { status: 400 })
}
if (file.size > MAX_BYTES || !ALLOWED.includes(file.type)) {
return NextResponse.json({ error: "File rejected" }, { status: 415 })
}
// `file.name` comes from the client and can contain `../` — never join it
// into a path. Keep only the extension and name the file yourself.
const name = `${randomUUID()}${path.extname(file.name).toLowerCase()}`
const bytes = Buffer.from(await file.arrayBuffer())
await writeFile(path.join(process.env.UPLOAD_DIR!, name), bytes)
return NextResponse.json({ url: `/uploads/${name}` })
}Express with Multer:
// server.js
import crypto from "node:crypto"
import path from "node:path"
import express from "express"
import multer from "multer"
const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ["image/png", "image/jpeg", "application/pdf"]
const upload = multer({
limits: { fileSize: MAX_BYTES, files: 1 },
storage: multer.diskStorage({
destination: process.env.UPLOAD_DIR,
// Same reason as above: the original name is untrusted input.
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
cb(null, `${crypto.randomUUID()}${ext}`)
},
}),
fileFilter: (_req, file, cb) => cb(null, ALLOWED.includes(file.mimetype)),
})
const app = express()
app.post("/api/upload", upload.single("file"), (req, res) => {
if (!req.file) return res.status(415).json({ error: "File rejected" })
res.json({ url: `/uploads/${req.file.filename}` })
})Multer rejects an oversized file with a LIMIT_FILE_SIZE error before it ever
reaches the handler, so add an error middleware if you want that answered as
JSON rather than as Express' default HTML error page.
The endpoint is yours, so the real validation belongs there. The component's
allowedFormats and maxFileSize checks are a UX aid for whoever uses the
form, not a security boundary — anyone can POST to the endpoint directly, so
re-check type and size on the server before storing anything.