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
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:
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:3000React / Next.js — public URL of your Node API:
# Next.js (.env.local)
NEXT_PUBLIC_API_URL=http://localhost:3001
# Vite React (.env)
VITE_API_URL=http://localhost:30012. Node.js — Reupload client
Thin wrapper around the Reupload HTTP API. Uses native fetch (Node 20+).
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.
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.
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.
"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:
| Layer | Where | Allows |
|---|---|---|
| Node.js → browser | cors() in server.js | Browser calling /uploads/prepare and /uploads/complete |
| CDN → browser | Reupload project settings | Browser 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:
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;dataincludesfileId,url,mimeType,sizeBytesfile.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).
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:
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/completeReturn 2xx quickly after enqueueing work. Failed deliveries are retried with backoff — inspect logs in the dashboard.
Integration checklist
- Create API key + project in the dashboard; copy env vars into your Node API.
- Call
GET /public/whoamifrom Node (or use the Try it panel) and confirm yourprojectslist includes the target project. - Add your React/Next origin to project upload CORS (localhost + production).
- Run
reupload.js+server.jswith CORS enabled. - Set
NEXT_PUBLIC_API_URLorVITE_API_URLand wireuploadFileViaBackendin a client component. - Upload a test file; poll until
COMPLETEDfor local dev. - Register a webhook for production; handle
file.uploadedon your Node API.
Production tips
- Rate-limit
POST /uploads/prepareon 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
fileIdin your database; Reupload has no public list endpoint. - Prefer
file.uploadedwebhooks over browser polling — see §8 Webhooks.
Related
- Webhooks — payload, signature, retries
- Django + reupload-sdk — Python stack with webhooks
- @reupload/sdk — typed Node SDK (optional replacement for hand-rolled
reupload.js)