DEV Community

Cover image for Add a Drive-style file manager to Next.js without giving up your bucket
Luis Manuel Yerena Sosa
Luis Manuel Yerena Sosa

Posted on

Add a Drive-style file manager to Next.js without giving up your bucket

file-next keeps the bytes in your S3 or R2 bucket and the folder tree in your database. Tenant comes from the server. The UI is optional.

You already know how to put a file in S3. The trouble starts the week after.

Someone wants folders. Then search. Then a trash can they can undo. Then a link they can send that does not expire into a raw bucket URL. Then a second customer, and the tenant id in the request body turns out to have been a suggestion.

Listing a bucket is not a filesystem. A dropzone is not a file manager. A hosted upload product will hold the bytes for you, which is fine until a customer asks whose account those files live in.

file-next is the layer in between. It is an open-source filesystem for Next.js. The bytes stay in your bucket. The tree stays in your SQLite or Postgres. You can drop in a Drive-like explorer, or skip the UI and use the hooks. There is a live demo if you want to click around before reading the rest.

Two stores, one filesystem

Object storage is excellent at bytes and bad at "what is in this folder, for this customer, excluding trash, matching this name." A database is the opposite.

file-next splits the job on purpose.

Browser  →  Next.js
              ├─ Metadata store (SQLite or Postgres)
              │    folders, names, owner, size, mime, search, trash, shares, quota
              └─ Object storage (S3 or R2)
                   bytes, keyed by node id under t/{tenantId}/
Enter fullscreen mode Exit fullscreen mode

Rename and move update a row. They do not copy the object. Copy writes a new id. The browser never picks the object key, and server actions never accept tenantId from the client. getAuth() does.

That last rule is the whole product. If the client can name the tenant, you do not have multi-tenant files. You have a bucket with a polite API.

What you actually install

Four packages, one job each. You do not need all of them.

You want… Package
S3/R2, the tree, server actions @vryzel/file-next
Your own UI, with state handled @vryzel/file-next-headless
A Drive-like explorer @vryzel/file-next-ui
"Is this environment wired?" @vryzel/file-next-cli

Version 0.5.0 is on npm. MIT.

pnpm add @vryzel/file-next @vryzel/file-next-headless @vryzel/file-next-ui
pnpm add better-sqlite3   # or pg, if that is the store you use
Enter fullscreen mode Exit fullscreen mode

better-sqlite3 and pg are optional peers. Install the one you use.

Wire the server before you touch a component

This is the part that matters. The explorer is a client. The filesystem is not.

// lib/files.ts
import {
  asTenantId,
  asUserId,
  createFileSystem,
  createSqliteStore,
} from "@vryzel/file-next";
import { createServerActions } from "@vryzel/file-next/server";
import { createWriteThrough } from "@vryzel/file-next/sync";

const store = createSqliteStore({ path: ".data/metadata.db" });

const fs = createFileSystem(
  {
    provider: "s3",
    bucket: process.env.FILE_NEXT_BUCKET!,
    region: process.env.FILE_NEXT_REGION ?? "us-east-1",
    credentials: {
      accessKeyId: process.env.FILE_NEXT_ACCESS_KEY_ID!,
      secretAccessKey: process.env.FILE_NEXT_SECRET_ACCESS_KEY!,
    },
  },
  { store, quotaBytes: 5 * 1024 * 1024 * 1024 },
);

const writeThrough = createWriteThrough(fs, store);

export function getFiles() {
  return { store, fs, writeThrough };
}

export const actions = createServerActions({
  store,
  fs,
  writeThrough,
  getAuth: async () => {
    const session = await auth(); // Clerk, Auth.js, your cookie. Your code.
    if (!session) throw new Error("Unauthorized");
    return {
      tenantId: asTenantId(session.tenantId),
      userId: asUserId(session.userId),
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

SQLite creates its schema on first use. That is the right store for one Next.js process. When you run more than one instance, switch the store, not the rest of the app:

import { createPostgresStore } from "@vryzel/file-next";

const store = createPostgresStore({
  connectionString: process.env.DATABASE_URL!,
});
Enter fullscreen mode Exit fullscreen mode

Postgres isolation is SET LOCAL app.current_tenant plus row-level security. The app filter is still there. RLS is the second lock, not a replacement for getAuth().

Cloudflare R2 is the same factory with provider: "r2" and an endpoint of https://<accountid>.r2.cloudflarestorage.com. No region required.

Every action returns a Result. Success is { ok: true, value }. Failure is { ok: false, error }. You do not fish for meaning in a thrown string.

// lib/file-actions.ts
"use server";
import { actions } from "./files";

export const listFiles = actions.listFiles;
export const searchFiles = actions.searchFiles;
export const listTrash = actions.listTrash;
export const createFolder = actions.createFolder;
export const deleteFile = actions.deleteFile;
export const moveFile = actions.moveFile;
export const copyFile = actions.copyFile;
export const restoreNode = actions.restoreNode;
export const purgeNode = actions.purgeNode;

export async function createShareLink(input: { id: string }) {
  const result = await actions.createShare(input);
  if (!result.ok) throw result.error;
  return result.value.url; // /api/share/{token}
}
Enter fullscreen mode Exit fullscreen mode

createServerActions also gives you prepareUpload, confirmUpload, setMetadata, resolveShare, and revokeShare. Import @vryzel/file-next/server only from server modules. The client-safe error types live at @vryzel/file-next/errors.

The explorer is one component

"use client";
import { FileExplorer } from "@vryzel/file-next-ui";
import {
  listFiles,
  searchFiles,
  listTrash,
  createFolder,
  deleteFile,
  moveFile,
  copyFile,
  restoreNode,
  purgeNode,
  createShareLink,
} from "@/lib/file-actions";

export function Files({
  folderId,
  onOpenFolder,
}: {
  folderId: string | null;
  onOpenFolder: (id: string) => void;
}) {
  return (
    <FileExplorer
      className="h-[70vh] overflow-hidden rounded-[10px] border border-border bg-card"
      tenantId="demo"
      parentId={folderId}
      listFiles={listFiles}
      searchFiles={searchFiles}
      listTrash={listTrash}
      requestUpload={requestUpload}
      actions={{
        deleteFile,
        moveFile,
        copyFile,
        createFolder,
        restoreNode,
        purgeNode,
        createShare: createShareLink,
        renameFile: (id, newName) =>
          moveFile({ id, newParentId: folderId, newName }).then(() => undefined),
      }}
      onOpenFolder={(folder) => onOpenFolder(folder.id)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

tenantId on the component is a client prop. It is not authorization. The server action ignores it and reads getAuth().

Keep those callbacks stable. A new arrow on every render refetches. Module-level server actions are stable. Inline arrows are not.

Scan the package so Tailwind does not purge the classes, and define the usual shadcn variables (--background, --border, --primary, --muted, --destructive, and the rest):

content: [
  "./app/**/*.{ts,tsx}",
  "./node_modules/@vryzel/file-next-ui/dist/**/*.{js,mjs}",
]
Enter fullscreen mode Exit fullscreen mode

What the default explorer already does:

  • List and grid, both virtualized. The next page loads as you scroll.
  • Search, trash, restore, and delete forever if you passed purgeNode.
  • Multi-file upload, OS drop, a queue with byte-weighted progress, cancel, and per-row remove.
  • New folder and rename in place. Enter or blur saves. Escape cancels.
  • Multi-select, shift-click, copy/paste, drag onto a folder.
  • A quota footer, if you pass usedBytes and quotaBytes.
  • Labels you can override, including a partial translation. extraFileAction is the hook for a product action in the menu without forking it. protectedIds refuses to trash rows your product still depends on.

Preview is a Lightbox. Images zoom with the controls, the wheel, or a double-click. PDF, text, video, and audio use the same frame. HTML is not previewed. That is intentional.

Put UploadQueueProvider on a layout if the upload should survive leaving the files page. Unmounting the explorer does not abort the XHR.

Uploads: one route, object first

The explorer asks you for a URL, then PUTs the file with XHR so the progress is real. The simplest route that matches that contract writes the object and the tree together.

// app/api/upload/route.ts
import { getFiles } from "@/lib/files";

export async function PUT(req: Request): Promise<Response> {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });

  const url = new URL(req.url);
  const name = url.searchParams.get("name");
  if (!name) {
    return Response.json(
      { ok: false, error: { message: "Missing name" } },
      { status: 400 },
    );
  }

  const parentId = url.searchParams.get("parentId");
  const result = await getFiles().writeThrough.writeThroughFile({
    tenantId: session.tenantId,
    ownerId: session.userId,
    parentId: parentId && parentId.length > 0 ? parentId : null,
    name,
    body: new Uint8Array(await req.arrayBuffer()),
    contentType: req.headers.get("content-type") ?? "application/octet-stream",
  });

  if (!result.ok) {
    const status = result.error.code === "QuotaExceeded" ? 413 : 400;
    return Response.json(
      { ok: false, error: { code: result.error.code, message: result.error.message } },
      { status },
    );
  }

  return Response.json({ ok: true, value: { id: result.value.id } });
}
Enter fullscreen mode Exit fullscreen mode
function requestUpload(file: {
  name: string;
  type: string;
  parentId: string | null;
}) {
  const parent = file.parentId ?? "";
  return Promise.resolve({
    url: `/api/upload?name=${encodeURIComponent(file.name)}&parentId=${encodeURIComponent(parent)}`,
    method: "PUT" as const,
    headers: { "Content-Type": file.type || "application/octet-stream" },
  });
}
Enter fullscreen mode Exit fullscreen mode

Write-through means object first, then the row. If the insert fails after the object lands, the key goes to pending_orphans instead of vanishing into a bucket nobody lists. reconcile() drains that list.

A same-folder upload whose name is taken is stored as report (1).pdf, not rejected. Folder create, move, and rename still conflict. Those are names a person chose. An upload collision is not.

Quota is checked here, before the write, when you passed quotaBytes to createFileSystem. The footer number is display. The QuotaExceeded error is the enforcement.

If the bytes should not pass through Next.js at all, the other door is prepareUpload plus confirmUpload: a presigned PUT, then a confirm that records the row. The headless useUploader is the client that threads the prepared id into that confirm. Do not expect FileExplorer's confirmUpload prop to do it. That prop is a refresh callback. It does not receive the upload id.

A share link that does not show the bucket

createShare returns { token, url } where url is /api/share/{token}. Mount a handler that streams the object through your app. The client never sees the key.

// app/api/share/[token]/route.ts
import { createShareRouteHandler } from "@vryzel/file-next/server";
import { getFiles } from "@/lib/files";

const { store, fs } = getFiles();
export const GET = createShareRouteHandler({ store, fs });
Enter fullscreen mode Exit fullscreen mode

Folders cannot be shared this way. Only files. Signed downloads default to Content-Disposition: attachment. inline is kept for preview types that do not execute as a page. HTML and SVG stay attachments. A user-uploaded HTML file plus an inline signed URL is a stored XSS bug wearing a feature badge.

Where this earns its keep

A multi-tenant SaaS vault. Invoices, contracts, onboarding docs, one folder tree per customer. One bucket, keys under t/{tenantId}/, every query filtered by the tenant from the session. Postgres RLS if a bug in the app filter is not an acceptable failure mode. IAM scoped to t/*, not to the whole bucket. A bucket per tenant is available when a customer requires their own key or offboarding by deleting the bucket. That is a separate createFileSystem({ bucket }), not a flag you flip on a shared product by accident.

"Send this file" inside your product. A support agent shares a PDF. The link is on your domain, you can revoke the token, and the bucket stays private. You are not emailing a presigned URL that outlives the permission you thought you granted.

An internal tool that needed a file browser yesterday. Admin uploads, ops exports, a folder of CSVs nobody wants to model as a table yet. SQLite, the default explorer, a route. You can replace the UI later without migrating the bytes.

A product that already has a design system. Six hooks, no Tailwind, no Radix: useFileBrowser, useFileExplorer, useUploader, useFileActions, useFileUrl, useDownloadProgress. You inject the server callbacks. The hook owns loading, error, selection, and optimistic rollback. If a delete fails, the previous list comes back.

"use client";
import { useFileBrowser } from "@vryzel/file-next-headless";
import { listFiles } from "@/lib/file-actions";

export function Folder({ parentId }: { parentId: string | null }) {
  const { status, files, error } = useFileBrowser({
    listFiles,
    tenantId: "demo",
    parentId,
    autoFetch: true,
  });

  if (status === "loading") return <p>Loading…</p>;
  if (status === "error") return <p>{error?.message}</p>;
  return (
    <ul>
      {files.map((file) => (
        <li key={file.id}>{file.name}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

A quota, per tenant, that is not a Stripe webhook and a prayer. Pass quotaBytes on the filesystem. Pass usedBytes to the explorer if you want the footer. The write fails with QuotaExceeded when the sum of stored sizes would cross the line. Display and enforcement are different jobs. Keep them that way.

What it will not do for you

file-next is not a hosted file SaaS, and it is not a CMS. It will not scan for malware, write your audit log, or pick your auth library. Encryption at rest is a bucket setting. CORS for a browser-direct PUT is your bucket config, limited to your origin. Secrets stay in FILE_NEXT_* and never in a client bundle.

The CLI doctor command is real. It checks that the environment is wired. migrate and reconcile in the published bin do nothing until you pass hooks, because the SQLite and Postgres stores already create their schema on first use. Most apps never need migrate.

It is also the wrong tool if you need collaborative editing, image transforms, or a public CDN in front of every asset. Those are different products. This one is the filesystem your app was about to invent, badly, in a route handler.

Try it

pnpm add @vryzel/file-next @vryzel/file-next-headless @vryzel/file-next-ui
Enter fullscreen mode Exit fullscreen mode

Bring your bucket. Bring your database. Stop listing S3 and calling it a folder.

Top comments (0)