Browser uploads
Let people upload files from your web app straight to SteadyLink storage, with progress, while your API key stays on your server.
On this page
This guide is for developers adding an upload button to their own web app: profile photos, attachments, customer documents. The browser sends the file bytes directly to SteadyLink's storage, so large files never pass through your server, and your SteadyLink API key never leaves your backend. You end up with two small server routes and a few lines of browser code. If you do not want to write any code, the upload widget is a ready-made embed for the same job.
How the flow works#
A browser upload is the normal three-part upload, split between your server and the browser:
- Your server creates an upload session. It authenticates your user, decides where the file goes, and calls
POST /api/upload-batcheswith your API key. SteadyLink returns a session ID and a presigneduploadUrl. - The browser PUTs the bytes to
uploadUrl. This request goes to object storage, carries no SteadyLink credentials, and can report progress. - Your server completes the session. It calls
POST /api/upload-sessions/{id}/complete, SteadyLink finalizes and scans the file, and your server gets the asset ID and link.
Two rules make this safe and are not optional:
- The API key stays on the server. Anyone who sees a key can use all of its scopes. The SteadyLink API also only accepts cross-origin browser requests from SteadyLink's own apps, so a browser call with a key from your site would fail anyway.
- The
uploadUrlis the only thing the browser needs. It allows one PUT of one file to one temporary location for 15 minutes. It cannot read, list, or overwrite anything else.
Step 1: create the session on your server#
This Next.js route handler authenticates the user, validates the file, and creates a one-file batch in a per-user folder. It uses the TypeScript SDK; the same request with curl is in the Uploads API.
import { SteadyLink } from "@steadylink/sdk";
import { getCurrentUser } from "@/lib/auth"; // your own auth
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
const BUCKET_ID = process.env.STEADYLINK_UPLOADS_BUCKET_ID!;
const MAX_BYTES = 25 * 1024 * 1024;
const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
export async function POST(request: Request) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Sign in to upload" }, { status: 401 });
const { filename, size, contentType } = await request.json();
if (typeof filename !== "string" || !Number.isInteger(size) || size <= 0 || size > MAX_BYTES) {
return Response.json({ error: "File must be smaller than 25 MB" }, { status: 400 });
}
if (!ALLOWED.has(contentType)) {
return Response.json({ error: "Upload a JPEG, PNG, WebP, or PDF" }, { status: 415 });
}
// One folder per user. The folder is checked again before completing.
const batch = await steadylink.createUploadBatch(BUCKET_ID, [
{ filename, size, contentType, path: `users/${user.id}/` },
]);
const session = batch.files[0]!;
return Response.json({
batchId: batch.id,
sessionId: session.id,
uploadUrl: session.uploadUrl,
});
}Things to decide here rather than in the browser:
- Where the file goes. Never take a bucket ID or folder from the request body. Here the folder is derived from the signed-in user.
- Names. A file uploaded to a key that already exists becomes a new revision of that file instead of a copy. If two uploads from the same user may share a name, prefix the filename with something unique, such as
${crypto.randomUUID()}-${filename}. - Size and type. Your checks give users fast, friendly errors. SteadyLink enforces its own limits as well: your plan's maximum file size (and a lower API-key ceiling on some plans), any limits set on the bucket, a content check that rejects files whose bytes do not match their type, and a malware scan. Setting a Maximum upload size on the destination bucket gives you a server-side backstop.
Step 2: send the bytes from the browser#
The browser PUTs the File object as the request body. Two details matter:
- The
Content-Typeheader must be exactlyapplication/octet-stream, whatever the file type. The URL is signed for that value, and storage rejects anything else with403. The real type was declared in step 1 and is detected again from the bytes. - Send no other headers and no credentials. In particular, do not add your session cookie or an
Authorizationheader.
fetch is enough when you do not need progress. For a progress bar, use XMLHttpRequest, whose upload.onprogress event reports bytes sent (fetch cannot report upload progress in most browsers).
"use client";
import { useState } from "react";
function putWithProgress(url: string, file: File, onProgress: (percent: number) => void) {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Storage returned ${xhr.status}`)));
xhr.onerror = () => reject(new Error("The upload lost its network connection"));
xhr.send(file);
});
}
export function UploadButton() {
const [status, setStatus] = useState("");
const [link, setLink] = useState<string | null>(null);
async function upload(file: File) {
setLink(null);
setStatus("Preparing upload");
const created = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, size: file.size, contentType: file.type }),
});
if (!created.ok) return setStatus((await created.json()).error ?? "Upload refused");
const { batchId, sessionId, uploadUrl } = await created.json();
try {
await putWithProgress(uploadUrl, file, (percent) => setStatus(`Uploading ${percent}%`));
} catch (error) {
return setStatus(error instanceof Error ? error.message : "Upload failed");
}
setStatus("Checking the file");
const completed = await fetch(`/api/uploads/${sessionId}/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ batchId }),
});
const result = await completed.json();
if (!completed.ok) return setStatus(result.error ?? "Upload failed");
setStatus("Done");
setLink(result.url);
}
return (
<div>
<input type="file" onChange={(event) => event.target.files?.[0] && void upload(event.target.files[0])} />
<p role="status" aria-live="polite">{status}</p>
{link && <a href={link}>{link}</a>}
</div>
);
}The same request without progress:
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body: file,
});Step 3: complete the upload on your server#
The completion route checks that the session belongs to the signed-in user, completes it, waits for finalization and the malware scan, and returns the link.
import { SteadyLink, SteadyLinkError, SteadyLinkTimeoutError } from "@steadylink/sdk";
import { getCurrentUser } from "@/lib/auth";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
export async function POST(request: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const user = await getCurrentUser();
if (!user) return Response.json({ error: "Sign in to upload" }, { status: 401 });
const { sessionId } = await params;
const { batchId } = await request.json();
// Only complete sessions this user created: their folder is part of the session.
const batch = await steadylink.getUploadBatch(batchId);
const session = batch.files.find((file) => file.id === sessionId);
if (!session || session.path !== `users/${user.id}/`) {
return Response.json({ error: "Upload not found" }, { status: 404 });
}
try {
await steadylink.completeUpload(sessionId); // sends Idempotency-Key: complete-{sessionId}
const finished = await steadylink.waitForUploads(batchId, [sessionId], { timeoutMs: 60_000 });
const file = finished.files.find((item) => item.id === sessionId)!;
if (file.status !== "ready") {
return Response.json({ error: file.error?.message ?? `Upload ${file.status}` }, { status: 422 });
}
return Response.json({ assetId: file.objectAssetId, url: steadylink.link(file.objectAssetId!) });
} catch (error) {
if (error instanceof SteadyLinkTimeoutError) {
return Response.json({ error: "Still processing. Check again shortly." }, { status: 202 });
}
if (error instanceof SteadyLinkError) {
return Response.json({ error: error.message }, { status: error.status === 410 ? 410 : 502 });
}
throw error;
}
}Notes on this step:
- Completion is idempotent. If the browser retries,
completeUploadsends the sameIdempotency-Key, and a session that already finished returns its final state. That is why it is safe to call from a route the user can hit twice. - Waiting is optional. You do not have to hold the request open while SteadyLink finalizes and scans the file. You can return right after
completeUploadand let the browser poll a status route that callsgetUploadBatch, or listen for theupload.updatedserver-sent events onGET /api/events/streamfrom your server. - Visibility. New files take the destination bucket's default. For user content that should not be public, keep the bucket private and hand out signed links. To make one file public, call
steadylink.setVisibility(BUCKET_ID, session.path + session.filename, "public")after it isready. - Size mismatches. If the bytes the browser sent do not match the declared
size, the session endsfailedwithupload_size_mismatch. This also catches a user picking a different file between steps.
Several files at once#
One batch holds up to 100 sessions, each with its own uploadUrl. Send all the file descriptions in step 1, PUT each file to its own URL (two to four at a time is a good balance), and complete each session separately. A file that fails does not affect the others; the batch status ends partial if any file was blocked or failed.
When storage is unreachable#
Some corporate networks block the storage host that presigned URLs point to while allowing your own domain. For those users you can route the bytes through your server, which forwards them to SteadyLink's API instead of storage:
import { SteadyLink } from "@steadylink/sdk";
const steadylink = new SteadyLink({ apiKey: process.env.STEADYLINK_API_KEY! });
export async function PUT(request: Request, { params }: { params: Promise<{ sessionId: string }> }) {
const { sessionId } = await params;
const batchId = new URL(request.url).searchParams.get("batch")!;
const batch = await steadylink.getUploadBatch(batchId);
const session = batch.files.find((file) => file.id === sessionId);
if (!session) return new Response("Not found", { status: 404 });
// Check ownership here exactly as in the completion route.
await steadylink.sendUploadBytes(session, request.body!, { via: "api" });
return new Response(null, { status: 204 });
}via: "api" streams the bytes to PUT /api/upload-sessions/{session_id}/content with your API key, so it must run on your server. That route requires the body to be exactly the declared size, accepts bytes until the session's expiresAt (not just the 15-minute URL lifetime), and leaves the session uploaded, ready to complete as usual. The trade-off is that every byte now passes through your server, so your hosting platform's request size and duration limits apply. Use it as a fallback, not the default. The SDK's upload() and the CLI accept the same via: "api" option (--via api) for server-side scripts on restricted networks.
Troubleshooting#
| Symptom | Cause |
|---|---|
403 from storage on the PUT | The Content-Type was not application/octet-stream, extra signed headers were added, or the URL is older than 15 minutes. Create a new session. |
Session failed with upload_size_mismatch | The bytes sent differ from the declared size. |
Session failed with upload_missing | Completion was called before the PUT finished, or the PUT never reached storage. |
410 from completion | The session expired. Create a new one. |
409 Idempotency key mismatch | Completion was retried with a different Idempotency-Key. Reuse the first one. |
413 with upload_size_limit when creating the session | The file is larger than your plan or API key allows. |
Session blocked | The file failed the malware scan or a bucket rule. Do not retry it. |
429 | Too many requests for your plan's rate limit. Retry after the Retry-After header. |