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#
uploadsDir()returns theuploads/folder in the project root in every mode:next devandnext start: the working directory.output: "standalone":server.jsmoves itself into.next/standalone/..., so the path before.nextis used. Files are written to the project root, not into the build output.
- With Docker or a persistent disk you can change the folder with environment variables:
CI_FINDER_UPLOADS_DIR=/data/uploadschanges only the uploads folder.CI_FINDER_ROOT=/appchanges the project root.
- Why not
public/? In production Next.js does not serve files added topublic/after the build.createUploadsRoutereads files from disk on every request. It supports Range (video seeking), ETag and 304, and serves HTML and SVG files withCSP: sandbox. - A standalone build does not copy
.next/staticby itself. examples/next/scripts/copy-standalone-assets.mjs does this in apostbuildstep. - In a monorepo, add
outputFileTracingRoottonext.config; see examples/next/next.config.mjs. - To keep thumbnails working in standalone, include sharp's native libraries. Next's file tracer does not pick up the
libvipsDLL/.sofiles on its own:// next.config.mjs outputFileTracingIncludes: { "/api/files": ["./node_modules/@img/**/*"] },If you forget this the file manager keeps working: the server logs a warning and sends the original images instead of thumbnails.
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
},
});
- Returning
falsegives 403, throwingUNAUTHORIZEDgives 401. - Returning
{ readOnly: true }makes every volume read-only for that request. The UI hides write actions by itself, and the API rejects writes with aREAD_ONLYerror. - When the UI receives a 401 it calls
onUnauthorized, for example<TheFinder onUnauthorized={() => location.assign("/login")} />. - To protect
/uploadsas well:createUploadsRoute({ authorize: (request) => isSignedIn(request) }).
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
},
];
- No AWS SDK; signing (SigV4) uses Web Crypto and also works on the Edge runtime.
- Copying and moving between the local disk and S3 is supported.
- Uploads use S3 multipart and the server keeps no state between requests, so it works in serverless environments too. With S3,
chunkSizemust be at least 5 MiB (the default is 5 MiB). - For the image editor to edit images stored in S3, the bucket needs a CORS rule (
GET, your app's origin). - Integration tests against a real S3-compatible server:
CI_FINDER_S3_ENDPOINT=http://127.0.0.1:7070 CI_FINDER_S3_KEY=... CI_FINDER_S3_SECRET=... npm run test:s3 -w @thefinder/coreThese tests were run against Versity Gateway 1.8 and all passed. They cover signature verification, Turkish and special-character keys, multipart uploads, paginated listing of more than 1,000 objects, presigned URLs, trash and thumbnails.
Thumbnails#
import { sharpThumbnailer } from "@thefinder/core/sharp";
createTheFinder({
volumes,
thumbnails: { generator: sharpThumbnailer() }, // sizes: [128, 256, 512], concurrency: 2
});
- Generated with
sharp. Next.js projects already ship it (next/imageuses it); elsewherenpm i sharpis enough. The core package itself does not depend on sharp; only the@thefinder/core/sharpsubpath imports it. - Generated on first request and stored in the hidden
.tf-thumbs/folder inside the volume (works on S3 too). Regenerated when the source image changes; cleaned up when the file is deleted, moved or renamed. The folder can be deleted at any time and is rebuilt when needed. - Abuse protection:
- Only the configured sizes are generated; arbitrary sizes cannot be requested.
- Concurrent generation is limited, and simultaneous requests for the same image are merged into one.
- Files larger than 40 MB and images larger than 120 megapixels are not thumbnailed.
- For unsupported or corrupt files the original is sent and the UI keeps working. If sharp cannot be loaded at all (for example the native binaries were not copied to the server), the API keeps working, logs a warning once and shows the original images.
- Thumbnails are served with
?v=<mtime>URLs, so browsers cache them for a year.
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 }
- Works the same on the local disk and on S3, and survives server restarts.
- A restored item goes back to its old place. If the old folder is gone it is recreated; if another item has the same name, it becomes "file (2)".
- Expired items are cleaned up whenever the trash is listed, so no cron is needed.
.tf-trashnever shows up in normal listings, search, zip downloads or the/uploadsroute, even withshowHidden: true.
{ 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
- A version is taken when: saving in an editor (code and image), overwriting during bulk optimization, uploading with "replace", pasting with "replace", restoring a version (the state before the restore becomes a version too, so it can be undone).
- On rename and move (folder moves included) the history follows the file. When a file goes to the trash its history stays and reattaches when it is restored; a permanent delete removes the history as well.
- Restoring a version of a file that was deleted outside theFinder recreates the file.
- Taking a version is a copy: a file copy on the local disk, a server-side
CopyObjecton S3 (data never passes through your server). Listing a file's history is a single folder listing.
{ 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
});
- Select images, right click → "Optimize images…": preset sizes (3840, 2560, 1920, 1280, 800) or a custom size, format, quality.
- Images are fitted into the box keeping their aspect ratio and are never upscaled. EXIF orientation is applied and metadata is stripped; animated GIF/WebP stay animated if the target format supports it.
- If the format changes (e.g. PNG → WebP) the original is kept and
photo.webpis created next to it. If the format stays the same the file is overwritten (its old content goes into version history) or an-optimizedcopy is created. - With "Skip images that don't get smaller" on, files whose result would be larger than the original are left alone. For PNG, a quality below 100 reduces it to a 256-color palette.
- At most two images are processed at once; inputs larger than 60 MB are rejected (
maxImageSize). - Without
images, the same dialog processes images in the browser (with canvas): PNG, JPEG and WebP.
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>
accept: the same syntax as<input type="file">("image/*",".pdf,.docx") or(entry) => boolean. Files that don't match are dimmed and cannot be selected.multiple,selectLabel,locale,themeand the other<TheFinder />props apply.absoluteUrls: truereturns full URLs.- Outside React (vanilla JS, Vue…) it's the same:
const files = await openFilePicker({ endpoint: "/api/files" }). popup: trueopens the picker in its own browser window, like elFinder;openFinderWindow({ endpoint })does the same for the whole file manager. No extra page to serve.- Working example: the
/playgroundpage inexamples/next.
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" },
});
- Picked images are inserted as images, other files as links (if text is selected, the link goes on it).
- In CKEditor 4 the "Browse Server" buttons of the Image and Link dialogs open theFinder too.
uploadFolderis created when missing;falsekeeps the editor's own upload setup. Usepickerfor picker options (accept,theme…) andopenPickerto plug in a picker of your own.- The last open-source release of CKEditor 4 is 4.22.1; 4.23 and later require a commercial license key. The connector works with both.
<TheFinder /> props#
| Prop | Description |
|---|---|
endpoint | API URL, e.g. /api/files |
headers, credentials | Extra 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" |
settings | Settings menu in the header (theme, view, density). The user's choice is stored under persistKey. Default true |
height, className, style | Size and style. Default height is 100% of the parent |
initialFolder, defaultView, persistKey | Start folder, default view and sorting, localStorage key for preferences |
rememberFolder | Reopen the folder the user was last in (default: true) |
onSelect, selectLabel, multiple | Picker mode (e.g. a "choose image" field in a CMS) |
accept, onCancel | Selectable files in picker mode ("image/*", ".pdf"…) and a "Cancel" button |
onOpen | Intercepts opening a file; return true to skip the default behavior |
onChange | Called after every change (upload, delete, move…) |
editors | Adds 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#
- Path traversal: Paths containing
..are not resolved, they are rejected outright. Symlinks pointing outside the root are hidden. - CSRF: Every mutating request needs the
x-thefinderheader; mutating commands do not run over GET. - Authorization: Per volume
readOnly,permission(action, path), extension allow and deny lists,maxUploadSize; per command theauthorizehook. - Archives: Zip-slip and zip-bomb protection (
maxExtractSize). - Internal folders:
.tf-trash,.tf-versionsand.tf-thumbscannot be reached by any command, search or the/uploadsURL, and no folder can be created with those names. - Active content: Uploaded HTML and SVG files are served with a sandbox CSP, so they cannot run scripts on your app's origin.
- Authentication: Authentication happens in the
authorizehook; with default settings anyone can reach the API. See Authorization for details.
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.
- When you make a change worth releasing, run
npx changeset. Pick the affected packages andpatch/minor/major, and write a short summary. Commit the generated.changeset/*.mdfile together with the change. - Every push to
mainruns the Release workflow. If there are pending changesets it opens or updates a "chore: version packages" PR that bumps versions and writes theCHANGELOG.mdfiles. - When that PR is merged the packages are built and published to npm, with provenance.
Requirements:
- The
thefinderorganization on npm (for the@thefinder/*scope). - An
NPM_TOKENrepository secret: a granular or Automation npm token with publish rights. - Settings › Actions › General › Allow GitHub Actions to create and approve pull requests enabled in the repo settings.
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.