Runqora UI
File browser

File browser

Browse, preview, upload, rename, move and delete the files in an S3 or R2 bucket.

NameActions

The preview runs against a bucket kept in your browser. Uploads, renames and deletes work, and are forgotten when the page reloads.

The browser works with the bucket you already have, on Amazon S3, Cloudflare R2 or another S3-compatible service. Files go straight between the browser and the bucket through signed URLs, so they never pass through your server.

Installation

npx shadcn@latest add @runqora/file-browser @runqora/storage-actions

file-browser is the interface. storage-actions adds Next.js server actions that sign requests to your bucket. Set them up as shown in server setup before you use the browser.

Using another backend? Install only @runqora/file-browser and write your own source.

Usage

Pass the server actions to the browser as its source:

app/files/page.tsx
import { FileBrowser } from "@/components/storage/file-browser";
import {
  createUpload,
  deleteObject,
  getDownloadUrl,
  getObject,
  list,
  moveObject,
} from "@/lib/storage/actions";

export default function FilesPage() {
  return (
    <FileBrowser
      rootLabel="Files"
      source={{
        createUpload,
        deleteObject,
        getDownloadUrl,
        getObject,
        list,
        moveObject,
      }}
    />
  );
}

Leave out a call and the browser hides what depends on it. Without createUpload, moveObject and deleteObject, people can browse and download but not change anything.

Keep the folder in the URL

The browser keeps the open folder in its own state. To make folders linkable, control it with prefix and onPrefixChange:

"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FileBrowser } from "@/components/storage/file-browser";
import type { StorageSource } from "@/lib/storage/types";

export function Files({ source }: { source: StorageSource }) {
  const router = useRouter();
  const pathname = usePathname();
  const prefix = useSearchParams().get("folder") ?? "";

  return (
    <FileBrowser
      onPrefixChange={(next) =>
        router.push(next ? `${pathname}?folder=${encodeURIComponent(next)}` : pathname)
      }
      prefix={prefix}
      source={source}
    />
  );
}

Use another backend

source is a StorageSource: an object of async functions that return a StorageResult, either { ok: true, data } or { ok: false, error }. The error is shown to people as it is. Implement it with your own API routes, tRPC, or anything else that can reach the bucket:

import type { StorageSource } from "@/lib/storage/types";

export const source: StorageSource = {
  async list({ prefix, startsWith, cursor }) {
    const response = await fetch(
      `/api/files?${new URLSearchParams({ prefix, startsWith: startsWith ?? "", cursor: cursor ?? "" })}`
    );
    return response.ok
      ? { ok: true, data: await response.json() }
      : { ok: false, error: "The folder couldn't be loaded." };
  },
};

Props

Prop

Type

StorageSource

Prop

Type

What it includes

Limits

  • Buckets can only search by the start of a key, so the filter matches names that start with what you type, in the open folder.
  • Rename and move copy the file, then delete the original. Large files take as long as a copy inside the bucket takes.
  • Uploads are single requests, so a file can be up to 5 GB. storage-actions allows 100 MB by default.

On this page