Guide

Getting started

Installation#

npm i @thefinder/core @thefinder/react @thefinder/next

Next.js#

1. Server setup (lib/finder.ts)

import { createTheFinder, localDriver, uploadsDir } from "@thefinder/next";

export const finder = createTheFinder({
  volumes: [
    {
      id: "uploads",
      name: "Uploads",
      driver: localDriver({ root: uploadsDir() }), // <project root>/uploads
      url: "/uploads",
      denyExtensions: ["php", "exe"],
    },
  ],
  authorize: async ({ request }) => Boolean(await getSession(request)), // optional
});

2. API route (app/api/files/route.ts)

import { createNextRoutes } from "@thefinder/next";
import { finder } from "@/lib/finder";

export const runtime = "nodejs";
export const { GET, POST } = createNextRoutes(finder);

3. Serving files (app/uploads/[...path]/route.ts)

import { createUploadsRoute } from "@thefinder/next";

export const runtime = "nodejs";
export const { GET, HEAD } = createUploadsRoute();

4. The page

"use client";
import { TheFinder } from "@thefinder/react";
import "@thefinder/react/styles.css";

export default function Files() {
  return (
    <div style={{ height: "100vh" }}>
      <TheFinder endpoint="/api/files" locale="en" />
    </div>
  );
}

Standalone and the uploads folder#

Authorization#

theFinder does not manage sessions; you read your app's session (Auth.js, Clerk, your own cookie…) in the authorize hook:

import { TheFinderError } from "@thefinder/next"; // or "@thefinder/core"

createTheFinder({
  volumes,
  authorize: async ({ request, cmd }) => {
    const session = await getSession(request);
    if (!session) throw new TheFinderError("UNAUTHORIZED");          // → 401
    if (session.role === "viewer") return { readOnly: true };       // everything read-only
    return true;                                                     // false → 403
  },
});

Bun#

import { createTheFinder, createFileServer } from "@thefinder/core";
import { localDriver } from "@thefinder/core/local";
import index from "./index.html";

const driver = localDriver({ root: "./uploads" });
const finder = createTheFinder({ volumes: [{ id: "files", driver, url: "/uploads" }] });

Bun.serve({
  routes: {
    "/": index, // Bun bundles the React UI itself
    "/api/files": finder.handler,
    "/uploads/*": createFileServer({ driver, prefix: "/uploads" }),
  },
});

Node.js, Express, Fastify, Koa#

import { toExpress, toNodeHandler, toFastify, toKoa } from "@thefinder/core/node";

app.all("/api/files", toExpress(finder.handler));                       // Express / Connect
http.createServer(toNodeHandler(finder.handler));                       // plain node:http
fastify.register(toFastify(finder.handler), { prefix: "/api/files" });  // Fastify
router.all("/api/files", toKoa(finder.handler));                        // Koa

Body parsers such as express.json() and koa-bodyparser cause no trouble; if the body was already read, the adapter rebuilds it. The adapters are tested against real Express 5, Fastify 5 and Koa 3 servers, covering JSON commands, chunked uploads, Range downloads and zip downloads. In Web-standard runtimes such as Hono, Deno and Cloudflare, use finder.handler directly.

S3, R2, MinIO#

import { s3Driver } from "@thefinder/core/s3";

volumes: [
  { id: "local", driver: localDriver({ root: uploadsDir() }), url: "/uploads" },
  {
    id: "s3",
    name: "Cloud",
    driver: s3Driver({
      bucket: "my-bucket",
      region: "auto",                                   // "auto" for R2
      endpoint: "https://<id>.r2.cloudflarestorage.com", // leave empty for AWS
      accessKeyId: process.env.S3_KEY!,
      secretAccessKey: process.env.S3_SECRET!,
      prefix: "uploads/",
    }),
    url: "https://cdn.example.com/uploads", // if you have a public bucket/CDN; otherwise presigned URLs are used
  },
];

Thumbnails#

import { sharpThumbnailer } from "@thefinder/core/sharp";

createTheFinder({
  volumes,
  thumbnails: { generator: sharpThumbnailer() }, // sizes: [128, 256, 512], concurrency: 2
});

Trash#

No database is used. Each volume keeps its own trash in a hidden .tf-trash/ folder:

uploads/.tf-trash/
  mgh2k1-a8f3c2d1/          ← the deleted item (with its original name)
    Invoices/...
  mgh2k1-a8f3c2d1.json      ← { name, originalPath, deletedAt, kind, size }
{ id: "uploads", driver, trash: { retentionDays: 14 } } // default: 30 days
{ id: "tmp", driver, trash: false }                      // no trash, delete right away

API commands: rm (moves to trash by default, permanent: true deletes for good), trash, restore, purge ({ ids } or { all: true }).

Version history#

Like the trash, no database. Before a file is overwritten, its old content is copied into the hidden .tf-versions/ folder inside the volume:

uploads/.tf-versions/
  3f0a…c9/                         ← sha1(file path)
    file.json                      ← { path }
    mgh2k1-a8f3c2-edit.bin         ← time-random-reason
    mgh3p0-19bd04-optimize.bin
{ id: "uploads", driver, versions: { maxPerFile: 20, retentionDays: 0 } } // default; 0 = keep forever
{ id: "tmp", driver, versions: false }

In the UI: right click → "Version history", the details panel, and the history button in the image editor. Bulk cleanup lives in the storage dashboard: versions older than X days, last N versions per file, versions of deleted files, all.

API commands: versions, version (GET, serves the version), revert, rmVersions, stats, cleanup ({ target: "versions", mode: "all" | "orphaned" | "older" | "keep" } or { target: "cache" }).

Bulk image processing#

import { sharpImages, sharpThumbnailer } from "@thefinder/core/sharp";

createTheFinder({
  volumes,
  thumbnails: { generator: sharpThumbnailer() },
  images: sharpImages(), // resize, compress, WebP / AVIF / JPEG / PNG
});

File picker: useFilePicker#

Opens theFinder as a modal picker and resolves a Promise with the selected files. Resolves null if the user cancels.

import { useFilePicker } from "@thefinder/react";

const picker = useFilePicker({ endpoint: "/api/files", accept: "image/*" });

<input value={cover} onChange={(e) => setCover(e.target.value)} />
<button onClick={async () => {
  const files = await picker.open();
  if (files) setCover(files[0].url); // "/uploads/cover.png"
}}>Choose file</button>

CKEditor#

@thefinder/ckeditor does what CKFinder does: it adds a file manager button to the toolbar and uploads pasted / dropped images through theFinder.

CKEditor 5

import { TheFinder } from "@thefinder/ckeditor";

ClassicEditor.create(el, {
  licenseKey: "GPL",
  plugins: [Essentials, Paragraph, Image, ImageUpload, Link, TheFinder],
  toolbar: ["bold", "link", "|", "theFinder"],
  theFinder: { endpoint: "/api/files", uploadFolder: "/editor" },
});

CKEditor 4

import { registerTheFinder } from "@thefinder/ckeditor/v4";

registerTheFinder(window.CKEDITOR);
CKEDITOR.replace("body", {
  extraPlugins: "thefinder,uploadimage",
  theFinder: { endpoint: "/api/files", uploadFolder: "/editor" },
});

<TheFinder /> props#

PropDescription
endpointAPI URL, e.g. /api/files
headers, credentialsExtra request headers (e.g. Authorization), cookie mode for cross-origin
locale, messages"en" / "tr", add or override translations
theme"auto" (default), "light", "dark"
skin"classic" (default), "macos" (Finder-like look)
density"comfortable" (default), "compact"
settingsSettings menu in the header (theme, view, density). The user's choice is stored under persistKey. Default true
height, className, styleSize and style. Default height is 100% of the parent
initialFolder, defaultView, persistKeyStart folder, default view and sorting, localStorage key for preferences
rememberFolderReopen the folder the user was last in (default: true)
onSelect, selectLabel, multiplePicker mode (e.g. a "choose image" field in a CMS)
accept, onCancelSelectable files in picker mode ("image/*", ".pdf"…) and a "Cancel" button
onOpenIntercepts opening a file; return true to skip the default behavior
onChangeCalled after every change (upload, delete, move…)
editorsAdds your own editor to the "Open with" menu

Theming#

All styles live in @layer thefinder, so any rule you write outside the layer wins. Colors are CSS custom properties:

.tf-root {
  --tf-accent: #0f766e;
  --tf-radius: 4px;
  --tf-font: "Inter", system-ui, sans-serif;
}
.tf-root[data-theme="dark"] {
  --tf-bg: #101418;
}

Security#

Development#

npm install
npm run build      # core → next → react → ckeditor
npm test           # core tests (Node)
npm run test:bun   # the same tests on Bun
npm run dev        # builds the packages and starts the Next example (http://localhost:3000)
npm run site:dev   # builds and serves the GitHub Pages site (http://localhost:4173); run npm run build first

Examples: examples/next, examples/bun, examples/express.

The site source lives in site/. The docs pages are generated from README.md, README.tr.md and docs/api/**/*.md; the live demo runs the real @thefinder/core engine in the browser with an in-memory driver. Every push to main publishes the site through the Pages workflow.

Versioning and releases#

Versions are managed with Changesets. The four packages are always released with the same version number.

  1. When you make a change worth releasing, run npx changeset. Pick the affected packages and patch / minor / major, and write a short summary. Commit the generated .changeset/*.md file together with the change.
  2. Every push to main runs the Release workflow. If there are pending changesets it opens or updates a "chore: version packages" PR that bumps versions and writes the CHANGELOG.md files.
  3. When that PR is merged the packages are built and published to npm, with provenance.

Requirements:

Manual releases work too: npm run version-packages, then npm login and npm run release.

License#

MIT © Alper Koşay

This page is generated from README.md.