Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

File Uploads

This guide implements a complete file upload flow: client-side upload via signed URLs, metadata storage in Data with RLS, image processing via Functions, and access control.

Prerequisites: Running Reactor server with [storage] and [data] configured.


[storage]
backend = "fs"
fs_base_path = "./.reactor/blobs"
signing_secret = "{{ env REACTOR_STORAGE_SIGNING_SECRET }}"
signed_url_expiry_secs = 3600
max_upload_size = 104857600 # 100 MB

-- migrations/003_uploads.sql
CREATE TABLE uploads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT,
storage_path TEXT NOT NULL,
bucket TEXT NOT NULL DEFAULT 'uploads',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'uploaded', 'processing', 'ready', 'failed')),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE uploads ENABLE ROW LEVEL SECURITY;
CREATE POLICY uploads_owner ON uploads
FOR ALL
USING (user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid)
WITH CHECK (user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid);

The recommended pattern: create a pending record, get a signed upload URL, upload directly to storage, then confirm.

sequenceDiagram
Client->>Data: Insert pending upload record
Client->>Storage: Request signed upload URL
Storage-->>Client: Signed PUT URL
Client->>Storage: PUT file bytes
Client->>Functions: Confirm upload (optional processing)
Functions->>Data: Update status to ready
src/lib/uploads.ts
import { createClient } from "@reactor/client";
const reactor = createClient({ url: "https://api.myapp.com" });
export async function initiateUpload(file: File) {
const storagePath = `${crypto.randomUUID()}/${file.name}`;
const { data: upload } = await reactor.data
.from("uploads")
.insert({
filename: file.name,
content_type: file.type,
size_bytes: file.size,
storage_path: storagePath,
bucket: "uploads",
status: "pending",
})
.select()
.single();
return upload;
}
export async function getSignedUploadUrl(upload: Upload) {
const { signed_url, token } = await reactor.storage
.from(upload.bucket)
.createSignedUploadUrl(upload.storage_path, {
contentType: upload.content_type,
expiresIn: 3600,
});
return { signed_url, token };
}
export async function uploadFile(file: File, signedUrl: string) {
const response = await fetch(signedUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
"Content-Length": String(file.size),
},
body: file,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
}
export async function confirmUpload(uploadId: string) {
// Trigger processing function
await reactor.functions.invoke("process-upload", {
upload_id: uploadId,
});
}
src/components/FileUploader.tsx
import { useState } from "react";
import { initiateUpload, getSignedUploadUrl, uploadFile, confirmUpload } from "../lib/uploads";
export function FileUploader({ onComplete }: { onComplete: (id: string) => void }) {
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
setProgress(10);
const upload = await initiateUpload(file);
setProgress(30);
const { signed_url } = await getSignedUploadUrl(upload);
setProgress(50);
await uploadFile(file, signed_url);
setProgress(80);
await confirmUpload(upload.id);
setProgress(100);
onComplete(upload.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
}
}
return (
<div>
<input type="file" onChange={handleFileSelect} accept="image/*,.pdf" />
{progress > 0 && progress < 100 && <progress value={progress} max={100} />}
{error && <p className="error">{error}</p>}
</div>
);
}

Generate thumbnails and update metadata after upload:

functions/process-upload/index.ts
import { ReactorClient } from "@reactor/client";
export default async function handler(req: Request) {
const { upload_id } = await req.json();
const reactor = ReactorClient.service();
const { data: upload } = await reactor.data
.from("uploads")
.select("*")
.eq("id", upload_id)
.single();
if (!upload) return Response.json({ error: "Not found" }, { status: 404 });
await reactor.data
.from("uploads")
.update({ status: "processing" })
.eq("id", upload_id);
try {
// Download original
const blob = await reactor.storage
.from(upload.bucket)
.download(upload.storage_path);
const metadata: Record<string, unknown> = {};
// Image processing
if (upload.content_type.startsWith("image/")) {
const thumbPath = upload.storage_path.replace(/(\.[^.]+)$/, "_thumb$1");
const thumbnail = await generateThumbnail(await blob.arrayBuffer());
await reactor.storage.from(upload.bucket).upload(thumbPath, thumbnail, {
contentType: upload.content_type,
});
metadata.thumbnail_path = thumbPath;
}
await reactor.data.from("uploads").update({
status: "ready",
metadata,
}).eq("id", upload_id);
return Response.json({ ok: true });
} catch (err) {
await reactor.data.from("uploads").update({ status: "failed" }).eq("id", upload_id);
throw err;
}
}

Never expose raw storage paths to clients. Generate time-limited download URLs:

export async function getDownloadUrl(uploadId: string) {
const { data: upload } = await reactor.data
.from("uploads")
.select("storage_path, bucket, status")
.eq("id", uploadId)
.single();
if (!upload || upload.status !== "ready") {
throw new Error("Upload not available");
}
const { signed_url } = await reactor.storage
.from(upload.bucket)
.createSignedUrl(upload.storage_path, 3600); // 1 hour
return signed_url;
}

For files over 100 MB, use multipart upload:

// Initiate multipart
const { upload_id, parts } = await reactor.storage
.from("uploads")
.createMultipartUpload(storagePath, {
contentType: file.type,
partSize: 10 * 1024 * 1024, // 10 MB parts
});
// Upload each part
const completedParts = [];
for (let i = 0; i < parts.length; i++) {
const chunk = file.slice(parts[i].start, parts[i].end);
const etag = await fetch(parts[i].signed_url, {
method: "PUT",
body: chunk,
}).then(r => r.headers.get("ETag"));
completedParts.push({ part_number: i + 1, etag });
}
// Complete
await reactor.storage.from("uploads").completeMultipartUpload(upload_id, completedParts);

RLS ensures users see only their uploads. Downloads require authenticated signed URL generation.


Schedule deletion of abandoned pending uploads:

{
"id": "cleanup-pending-uploads",
"handler": "cleanup-uploads",
"triggers": [{
"type": "cron",
"schedule": "0 * * * *"
}]
}
functions/cleanup-uploads/index.ts
const cutoff = new Date(Date.now() - 3600_000).toISOString(); // 1 hour
const { data: stale } = await reactor.data
.from("uploads")
.select("id, storage_path, bucket")
.eq("status", "pending")
.lt("created_at", cutoff);
for (const upload of stale ?? []) {
await reactor.storage.from(upload.bucket).remove([upload.storage_path]);
await reactor.data.from("uploads").delete().eq("id", upload.id);
}

See Scheduled jobs for cron setup.


StepCapabilityAction
Create recordDataInsert pending upload with RLS
Get upload URLStorageSigned PUT URL
Upload bytesStorageDirect client → storage (no server proxy)
ProcessFunctionsThumbnails, validation, metadata
DownloadStorageSigned GET URL with expiry