File Uploads

How to upload files using presigned URLs, multipart uploads, and Tus resumable uploads with @nebutra/uploads.

@nebutra/uploads supports three upload strategies. Use presigned URLs for simple uploads under 100 MB, multipart for large files, and Tus for resumable uploads over unreliable connections.

Presigned URL upload

The most common pattern. Your server mints a short-lived URL; the client uploads directly to the storage provider without touching your server.

Set your storage provider credentials. See Providers for the full variable reference.

AWS_BUCKET_NAME=nebutra-uploads
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...

Generate the presigned URL on the server, scoped to the authenticated tenant.

// app/api/upload/presigned/route.ts
import { getUploadProvider } from "@nebutra/uploads";
import { getCurrentTenant } from "@nebutra/tenant";
import { z } from "zod";

const bodySchema = z.object({
  filename: z.string().min(1).max(255),
  contentType: z.string().min(1),
  size: z.number().int().positive().max(100 * 1024 * 1024), // 100 MB
});

export async function POST(request: Request) {
  const tenant = getCurrentTenant();
  const body = bodySchema.parse(await request.json());

  // Validate content type server-side
  const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
  if (!allowedTypes.includes(body.contentType)) {
    return Response.json({ error: "File type not allowed" }, { status: 422 });
  }

  const uploads = await getUploadProvider();
  const key = `${tenant.tenantId}/${Date.now()}-${body.filename}`;

  const { url, headers } = await uploads.createPresignedUpload({
    bucket: process.env.AWS_BUCKET_NAME!,
    key,
    contentType: body.contentType,
    tenantId: tenant.tenantId,
    expiresIn: 3600,
  });

  return Response.json({ url, headers, key });
}

Use the presigned URL to upload directly to the storage provider.

async function uploadFile(file: File): Promise<string> {
  // 1. Get presigned URL from your server
  const res = await fetch("/api/upload/presigned", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
      size: file.size,
    }),
  });

  if (!res.ok) throw new Error("Failed to get upload URL");
  const { url, headers, key } = await res.json();

  // 2. Upload directly to S3/R2
  const uploadRes = await fetch(url, {
    method: "PUT",
    headers: {
      "Content-Type": file.type,
      ...headers,
    },
    body: file,
  });

  if (!uploadRes.ok) throw new Error("Upload failed");
  return key;
}

After upload, save the file key in your database so you can retrieve it later.

const key = await uploadFile(file);

await db.attachment.create({
  data: {
    key,
    filename: file.name,
    contentType: file.type,
    size: file.size,
    tenantId: tenant.tenantId,
  },
});

React hook with progress

Use this hook to track upload progress in your UI components.

// hooks/use-file-upload.ts
import { useState, useCallback } from "react";

interface UploadState {
  progress: number;
  status: "idle" | "uploading" | "done" | "error";
  key: string | null;
  error: string | null;
}

export function useFileUpload() {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    status: "idle",
    key: null,
    error: null,
  });

  const upload = useCallback(async (file: File) => {
    setState({ progress: 0, status: "uploading", key: null, error: null });

    try {
      const res = await fetch("/api/upload/presigned", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        }),
      });

      if (!res.ok) throw new Error("Failed to get upload URL");
      const { url, headers, key } = await res.json();

      // Use XMLHttpRequest for progress events
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        xhr.upload.onprogress = (event) => {
          if (event.lengthComputable) {
            setState((prev) => ({
              ...prev,
              progress: Math.round((event.loaded / event.total) * 100),
            }));
          }
        };

        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) resolve();
          else reject(new Error(`Upload failed with status ${xhr.status}`));
        };

        xhr.onerror = () => reject(new Error("Network error"));

        xhr.open("PUT", url);
        Object.entries(headers ?? {}).forEach(([k, v]) =>
          xhr.setRequestHeader(k, v as string)
        );
        xhr.setRequestHeader("Content-Type", file.type);
        xhr.send(file);
      });

      setState({ progress: 100, status: "done", key, error: null });
    } catch (err) {
      setState({
        progress: 0,
        status: "error",
        key: null,
        error: err instanceof Error ? err.message : "Upload failed",
      });
    }
  }, []);

  return { ...state, upload };
}

Multipart upload (files over 10 MB)

For large files, split the upload into parts. Parts are uploaded in parallel and assembled by the storage provider.

import { getUploadProvider } from "@nebutra/uploads";

const uploads = await getUploadProvider();

// Initiate a multipart upload (10 parts)
const mp = await uploads.createMultipartUpload(
  {
    bucket: "nebutra-uploads",
    key: `${tenantId}/videos/demo.mp4`,
  },
  10 // number of parts
);

// mp.uploadId       — multipart session identifier
// mp.parts          — array of { partNumber, presignedUrl }

// Upload each part from the client
const uploadedParts = await Promise.all(
  mp.parts.map(async ({ partNumber, presignedUrl }) => {
    const start = (partNumber - 1) * partSize;
    const end = Math.min(start + partSize, file.size);
    const chunk = file.slice(start, end);

    const res = await fetch(presignedUrl, { method: "PUT", body: chunk });
    const etag = res.headers.get("ETag")!;
    return { partNumber, etag };
  })
);

// Complete the multipart upload
await uploads.completeMultipartUpload(
  "nebutra-uploads",
  mp.key,
  mp.uploadId,
  uploadedParts
);

Each part must be at least 5 MB (except the last part). The minimum recommended chunk size is 10 * 1024 * 1024 (10 MB).

Tus resumable upload

The @nebutra/uploads Tus adapter is under development. The integration will follow the tus-js-client interface with the UploadProvider abstraction.

Until the adapter ships, use the multipart upload approach (see above) for files over 100 MB. For files that need resume-on-failure today, call the S3/R2 multipart API directly:

// Persist the uploadId + ETags so the upload can resume after a browser refresh
const { uploadId, key } = await uploads.createMultipartUpload(
  { bucket: "nebutra-uploads", key: "videos/demo.mp4" },
  10  // number of parts
);

// Store uploadId in localStorage or sessionStorage
localStorage.setItem("pendingUpload", JSON.stringify({ uploadId, key, etags: [] }));

// On resume, load from storage and call completeMultipartUpload with collected ETags

Track the Tus integration in the changelog or watch the packages/integrations/uploads/ directory for the tus.ts adapter file.

Server-side file validation

Always validate file type and size on the server before issuing a presigned URL. Never rely on client-provided MIME types alone.

import { z } from "zod";

const ALLOWED_MIME_TYPES = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/gif",
  "application/pdf",
  "text/csv",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);

const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB

const uploadRequestSchema = z.object({
  filename: z
    .string()
    .min(1)
    .max(255)
    .regex(/^[\w\-. ]+$/, "Filename contains invalid characters"),
  contentType: z.string().refine(
    (type) => ALLOWED_MIME_TYPES.has(type),
    "File type is not allowed"
  ),
  size: z
    .number()
    .int()
    .positive()
    .max(MAX_FILE_SIZE, "File exceeds the 100 MB limit"),
});

MIME type validation alone is insufficient. For sensitive upload scenarios, run server-side magic-byte inspection (e.g. with the file-type npm package) after the file lands in the bucket.

How is this guide?

Edit on GitHub

Last updated on

On this page