Try it

Pick a file once — Create session and PUT upload use the same name, type, and size.

Upload file
No file chosen

Required for upload session and CDN PUT examples.

Guides

React/Next + Node.js

End-to-end browser uploads with a Node.js API holding your Reupload API key and a React or Next.js front end that never sees it. The browser PUTs file bytes to a signed CDN URL after your server creates an upload session — the recommended path for most apps. Use webhooks on your Node API when processing finishes instead of polling from the browser.

Need the browser to upload only to your API (no CDN PUT)? See the Server-side upload guide instead.

Architecture

CDN flow: your server creates a session; the client PUTs to uploadUrl; your server completes the session.

Browser

React / Next

Your API

Node.js

Reupload

API

CDN

Storage

POST /uploads/prepare
POST /uploads/session
uploadUrl, uploadId
uploadUrl, uploadId
PUT file bytes
POST /uploads/complete
POST /uploads/complete
202 processing
fileId, status
RequestResponse

Official packages: @reupload/sdk, @reupload/client, @reupload/react — see Packages. Also: Browser uploads (CORS), Client-side uploads, Server-side upload guide, Quickstart.

Prerequisites

  • A Reupload account, project, and API key with files.read + files.write sign up
  • Node.js 20+ API (Express below) and a React or Next.js app — separate repos or a monorepo
  • Your front-end origin added to the project's upload CORS settings in the dashboard (e.g. http://localhost:3000)

1. Environment variables

Node.js API (.env) — server only, never expose to the browser:

.env
REUPLOAD_API_BASE_URL=https://api.reupload.dev/api/v1
REUPLOAD_API_KEY=ru_xxxxxxxx
REUPLOAD_PROJECT_ID=your-project-uuid
 
# Webhooks (dashboard signing secret, whsec_… — shown once)
REUPLOAD_WEBHOOK_SECRET=whsec_xxxxxxxx
 
PORT=3001
CORS_ORIGIN=http://localhost:3000

React / Next.js — public URL of your Node API:

.env.local
# Next.js (.env.local)
NEXT_PUBLIC_API_URL=http://localhost:3001
 
# Vite React (.env)
VITE_API_URL=http://localhost:3001

2. Node.js — Reupload client

Thin wrapper around the Reupload HTTP API. Uses native fetch (Node 20+).

reupload.js
const apiBase = process.env.REUPLOAD_API_BASE_URL.replace(/\/$/, "");
const apiKey = process.env.REUPLOAD_API_KEY;
const projectId = process.env.REUPLOAD_PROJECT_ID;
 
function authHeaders() {
  return {
    Authorization: `Bearer ${apiKey}`,
    Accept: "application/json",
    "Content-Type": "application/json",
  };
}
 
async function reuploadRequest(method, path, body) {
  const res = await fetch(`${apiBase}${path}`, {
    method,
    headers: authHeaders(),
    body: body ? JSON.stringify(body) : undefined,
  });
 
  const text = await res.text();
  const payload = text ? JSON.parse(text) : null;
 
  if (!res.ok) {
    const message = payload?.message ?? `Reupload ${method} ${path} failed (${res.status})`;
    const err = new Error(message);
    err.status = res.status;
    throw err;
  }
 
  return payload;
}
 
export function createUploadSession({ filename, contentType, size }) {
  return reuploadRequest("POST", "/uploads/session", {
    projectId,
    filename,
    contentType,
    size,
  });
}
 
export function completeUpload(uploadId) {
  return reuploadRequest("POST", "/uploads/complete", { uploadId });
}
 
export function getUploadSession(uploadId) {
  return reuploadRequest("GET", `/uploads/session/${uploadId}`);
}
 
export function getFileAccess(fileId) {
  return reuploadRequest("GET", `/files/${fileId}/access?expiresIn=3600`);
}

3. Node.js — Express API

Install dependencies: npm install express cors dotenv. Routes your React/Next app calls. Validate input before forwarding to Reupload.

server.js
import "dotenv/config";
import express from "express";
import cors from "cors";
import {
  completeUpload,
  createUploadSession,
  getFileAccess,
  getUploadSession,
} from "./reupload.js";
 
const app = express();
const port = Number(process.env.PORT ?? 3001);
 
app.use(
  cors({
    origin: process.env.CORS_ORIGIN ?? "http://localhost:3000",
    credentials: true,
  }),
);
app.use(express.json());
 
app.post("/uploads/prepare", async (req, res) => {
  try {
    const { filename, contentType, size } = req.body ?? {};
 
    if (!filename?.trim()) {
      return res.status(400).json({ message: "filename is required" });
    }
    if (!contentType?.trim()) {
      return res.status(400).json({ message: "contentType is required" });
    }
    if (!Number.isInteger(size) || size <= 0) {
      return res.status(400).json({ message: "size must be a positive integer" });
    }
 
    const session = await createUploadSession({
      filename: filename.trim(),
      contentType: contentType.trim(),
      size,
    });
 
    res.json({
      uploadUrl: session.uploadUrl,
      uploadId: session.uploadId,
      fileId: session.fileId,
      expiresIn: session.expiresIn,
    });
  } catch (err) {
    res.status(err.status ?? 500).json({ message: err.message });
  }
});
 
app.post("/uploads/complete", async (req, res) => {
  try {
    const uploadId = req.body?.uploadId?.trim();
    if (!uploadId) {
      return res.status(400).json({ message: "uploadId is required" });
    }
    const result = await completeUpload(uploadId);
    res.json(result);
  } catch (err) {
    res.status(err.status ?? 500).json({ message: err.message });
  }
});
 
app.get("/uploads/:uploadId/status", async (req, res) => {
  try {
    const { session } = await getUploadSession(req.params.uploadId);
    res.json({ status: session.status, fileId: session.fileId });
  } catch (err) {
    res.status(err.status ?? 500).json({ message: err.message });
  }
});
 
app.get("/files/:fileId/access", async (req, res) => {
  try {
    const result = await getFileAccess(req.params.fileId);
    res.json(result);
  } catch (err) {
    res.status(err.status ?? 500).json({ message: err.message });
  }
});
 
app.listen(port, () => {
  console.log(`API listening on http://localhost:${port}`);
});

Add to package.json: "type": "module" and "start": "node server.js". Run node server.js and confirm POST http://localhost:3001/uploads/prepare responds with a valid key and project.

4. React / Next.js — Upload helper

Client helper for prepare → CDN PUT → complete. Works in Next.js, Vite React, or any browser bundle. No Reupload secrets in the front end. Prefer the official packages: @reupload/client or ready-made @reupload/react uploaders.

lib/reupload-client.js
const API_URL =
  process.env.NEXT_PUBLIC_API_URL ??
  process.env.VITE_API_URL ??
  "http://localhost:3001";
 
export async function uploadFileViaBackend(file) {
  const prepareRes = await fetch(`${API_URL}/uploads/prepare`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type || "application/octet-stream",
      size: file.size,
    }),
  });
 
  if (!prepareRes.ok) {
    const err = await prepareRes.json().catch(() => ({}));
    throw new Error(err.message ?? "Failed to prepare upload");
  }
 
  const { uploadUrl, uploadId, fileId } = await prepareRes.json();
 
  const putRes = await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type || "application/octet-stream" },
    body: file,
  });
 
  if (!putRes.ok) {
    throw new Error(`CDN upload failed (${putRes.status})`);
  }
 
  const completeRes = await fetch(`${API_URL}/uploads/complete`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ uploadId }),
  });
 
  if (!completeRes.ok) {
    const err = await completeRes.json().catch(() => ({}));
    throw new Error(err.message ?? "Failed to complete upload");
  }
 
  const complete = await completeRes.json();
  return {
    fileId: complete.fileId ?? fileId,
    uploadId,
    status: complete.status,
  };
}
 
export async function pollUploadStatus(uploadId, opts = {}) {
  const intervalMs = opts.intervalMs ?? 1500;
  const maxAttempts = opts.maxAttempts ?? 40;
 
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fetch(`${API_URL}/uploads/${uploadId}/status`);
    if (!res.ok) throw new Error("Failed to poll upload status");
    const data = await res.json();
    if (data.status === "COMPLETED" || data.status === "FAILED") {
      return data;
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
 
  throw new Error("Upload processing timed out");
}

5. React / Next.js — Upload component

Minimal upload UI. In Next.js App Router, mark the component "use client". In Vite React, use as a normal component.

components/FileUpload.tsx
"use client"; // Next.js only — omit in Vite React
 
import { useState } from "react";
import { pollUploadStatus, uploadFileViaBackend } from "../lib/reupload-client";
 
export function FileUpload() {
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState(null);
 
  async function onFileChange(e) {
    const file = e.target.files?.[0];
    if (!file) return;
 
    setBusy(true);
    setMessage(null);
 
    try {
      const result = await uploadFileViaBackend(file);
      setMessage(`Processing… fileId ${result.fileId}`);
 
      // Dev/demo: poll until ready. Production: rely on file.uploaded webhook
      // on your API and notify the UI (SSE, refetch, etc.) — see #webhooks.
      const final = await pollUploadStatus(result.uploadId);
      if (final.status === "COMPLETED") {
        setMessage(`Done — fileId ${final.fileId}`);
      } else {
        setMessage(`Upload failed: ${final.status}`);
      }
    } catch (err) {
      setMessage(err instanceof Error ? err.message : "Upload failed");
    } finally {
      setBusy(false);
      e.target.value = "";
    }
  }
 
  return (
    <div>
      <input type="file" disabled={busy} onChange={onFileChange} />
      {busy ? <p>Uploading…</p> : null}
      {message ? <p>{message}</p> : null}
    </div>
  );
}
 
// Next.js — app/upload/page.tsx
import { FileUpload } from "@/components/FileUpload";
 
export default function UploadPage() {
  return (
    <main>
      <h1>Upload a file</h1>
      <FileUpload />
    </main>
  );
}
 
// Vite React — App.tsx
// import { FileUpload } from "./components/FileUpload";
// export default function App() {
//   return <FileUpload />;
// }

6. CORS checklist

Two separate CORS layers must be configured:

LayerWhereAllows
Node.js → browsercors() in server.jsBrowser calling /uploads/prepare and /uploads/complete
CDN → browserReupload project settingsBrowser PUT to uploadUrl (e.g. http://localhost:3000)

If the CDN PUT fails with a CORS error in DevTools, add your front-end origin in the dashboard — not in Node.js. Details: Browser uploads → CORS.

7. Optional — download link

After COMPLETED, the Node API already exposes GET /files/:fileId/access. From the browser:

download snippet
const accessRes = await fetch(`${API_URL}/files/${fileId}/access`);
const { access } = await accessRes.json();
window.open(access.url, "_blank");

8. Webhooks

After POST /uploads/complete, Reupload processes the file in the background. The browser should not poll forever at scale — register a webhook in the dashboard that POSTs to your Node API (e.g. https://api.example.com/webhooks/reupload). There is no public API to create webhooks programmatically in the current release.

Common event types:

  • file.uploaded — processing finished; data includes fileId, url, mimeType, sizeBytes
  • file.updated — metadata changed (e.g. rename)
  • file.deleted — file removed

file.uploaded fires when the file is safe to use — not when /uploads/complete returns. Payload envelope, signature rules, and retries: Webhooks.

Next.js App Router

Use a Route Handler. Read the raw body before JSON.parse and verify Reupload-Signature with REUPLOAD_WEBHOOK_SECRET (whsec_… from the dashboard).

app/api/webhooks/reupload/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
 
const MAX_AGE_SECONDS = 5 * 60;
 
function parseSignatureHeader(header: string | null): { t: number; v1: string } | null {
  if (!header?.trim()) return null;
  let t: number | null = null;
  let v1: string | null = null;
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=", 2);
    if (!key || value === undefined) continue;
    if (key === "t") {
      const parsed = Number.parseInt(value, 10);
      if (!Number.isNaN(parsed)) t = parsed;
    }
    if (key === "v1") v1 = value;
  }
  if (t === null || !v1) return null;
  return { t, v1 };
}
 
function safeEqualHex(a: string, b: string): boolean {
  try {
    const bufA = Buffer.from(a, "hex");
    const bufB = Buffer.from(b, "hex");
    if (bufA.length !== bufB.length) return false;
    return timingSafeEqual(bufA, bufB);
  } catch {
    return false;
  }
}
 
function verifyReuploadSignature(secret: string, rawBody: string, header: string | null) {
  const parsed = parseSignatureHeader(header);
  if (!parsed) return { ok: false as const, reason: "missing_or_malformed" };
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parsed.t) > MAX_AGE_SECONDS) {
    return { ok: false as const, reason: "stale_timestamp" };
  }
  const expected = createHmac("sha256", secret)
    .update(`${parsed.t}.${rawBody}`)
    .digest("hex");
  if (!safeEqualHex(expected, parsed.v1)) {
    return { ok: false as const, reason: "invalid_signature" };
  }
  return { ok: true as const };
}
 
export async function POST(req: Request) {
  const secret = process.env.REUPLOAD_WEBHOOK_SECRET;
  if (!secret) {
    return Response.json({ error: "missing_secret" }, { status: 500 });
  }
 
  const rawBody = await req.text();
  const verified = verifyReuploadSignature(
    secret,
    rawBody,
    req.headers.get("Reupload-Signature"),
  );
  if (!verified.ok) {
    return Response.json({ error: verified.reason }, { status: 401 });
  }
 
  const event = JSON.parse(rawBody);
  if (event.type === "file.uploaded") {
    const { fileId, url, projectId } = event.data ?? {};
    // Update your DB, enqueue a job, notify the user, etc.
  }
 
  return Response.json({ received: true });
}

Express (same stack as above)

Mount the webhook before express.json() on that path, or use express.raw so the body stays unparsed for HMAC verification:

server.js (webhook route)
import { createHmac, timingSafeEqual } from "node:crypto";
 
// Reuse verifyReuploadSignature from the Next.js example (same logic).
 
app.post(
  "/webhooks/reupload",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const secret = process.env.REUPLOAD_WEBHOOK_SECRET;
    if (!secret) {
      return res.status(500).json({ error: "missing_secret" });
    }
 
    const rawBody = req.body.toString("utf8");
    const verified = verifyReuploadSignature(
      secret,
      rawBody,
      req.get("Reupload-Signature") ?? null,
    );
    if (!verified.ok) {
      return res.status(401).json({ error: verified.reason });
    }
 
    const event = JSON.parse(rawBody);
    if (event.type === "file.uploaded") {
      // fileId, url in event.data
    }
 
    res.json({ received: true });
  },
);
 
// Keep express.json() for /uploads/prepare and /uploads/complete

Return 2xx quickly after enqueueing work. Failed deliveries are retried with backoff — inspect logs in the dashboard.

Integration checklist

  1. Create API key + project in the dashboard; copy env vars into your Node API.
  2. Call GET /public/whoami from Node (or use the Try it panel) and confirm your projects list includes the target project.
  3. Add your React/Next origin to project upload CORS (localhost + production).
  4. Run reupload.js + server.js with CORS enabled.
  5. Set NEXT_PUBLIC_API_URL or VITE_API_URL and wire uploadFileViaBackend in a client component.
  6. Upload a test file; poll until COMPLETED for local dev.
  7. Register a webhook for production; handle file.uploaded on your Node API.

Production tips

  • Rate-limit POST /uploads/prepare on your Node API (per user/IP).
  • Validate file type and max size on your server before calling Reupload — see supported file types (default 50 MB per file).
  • Store fileId in your database; Reupload has no public list endpoint.
  • Prefer file.uploaded webhooks over browser polling — see §8 Webhooks.

Related