Supabase Storage delete file from Next.js, with a hold-to-delete file card

Supabase deletes a file with storage.from(bucket).remove([path]). It's free, and with two RLS policies on storage.objects it's the whole job: one server action and any button. The catch that sends people searching is that a blocked delete doesn't throw. You get an empty array back and the file stays. If you'd also like a delete that can't fire on a misclick, Hold to Delete shows the file as a card, asks for a 1.2 second hold and gives five seconds to undo before remove() runs.

Hold to Delete, preview
One payment$3

By buying you agree to the Terms.

Ready forCursorv0Lovableshadcn CLI

Full product pageReact · Tailwind · TypeScript

Supabase Storage delete file: the server action (free, works with any button)

app/files/actions.ts

"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";

const BUCKET = "documents";

export async function deleteFile(path: string): Promise<{ error?: string }> {
  // the signed-in user's own client: your Storage RLS policies decide what it may delete
  const supabase = await createClient();
  const { data: auth } = await supabase.auth.getClaims();
  if (!auth?.claims.sub) return { error: "You're signed out. Sign in and try again." };

  const { data, error } = await supabase.storage.from(BUCKET).remove([path]);
  if (error) return { error: error.message };

  // remove() answers with the objects it actually deleted. A path your policies
  // block, or a path that isn't there, is left out of that list with no error.
  if (data.length === 0) {
    return { error: "Nothing was deleted. The file is gone already, or your delete policy doesn't cover it." };
  }

  revalidatePath("/files");
  return {};
}

supabase/storage-delete-policies.sql (run once in the SQL editor)

-- remove() needs DELETE and SELECT on storage.objects (supabase-js 2.118.0 notes).
-- These two let a signed-in user delete only the files they uploaded to "documents".

create policy "Users can read their own documents"
on storage.objects for select
to authenticated
using (
  bucket_id = 'documents'
  and owner_id = (select auth.uid()::text)
);

create policy "Users can delete their own documents"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'documents'
  and owner_id = (select auth.uid()::text)
);

lib/supabase/server.ts (Supabase's SSR client)

import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

// Supabase's own server client for Next.js (from its SSR guide)
export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
          } catch {
            // called from a Server Component; the proxy refreshes the session
          }
        },
      },
    },
  );
}

app/files/free-delete-button.tsx (the free route: a plain confirm)

"use client";
import { useTransition } from "react";
import { deleteFile } from "./actions";

// The free route: the same server action behind a plain confirm.
// Swap window.confirm for shadcn's AlertDialog if you use shadcn.
export function FreeDeleteButton({ path, name }: { path: string; name: string }) {
  const [pending, startTransition] = useTransition();
  return (
    <button
      type="button"
      disabled={pending}
      onClick={() => {
        if (!window.confirm(`Delete ${name}? This can't be undone.`)) return;
        startTransition(async () => {
          const { error } = await deleteFile(path);
          if (error) alert(error);
        });
      }}
    >
      {pending ? "Deleting…" : "Delete"}
    </button>
  );
}

lib/storage/empty-folder.ts (delete a folder)

// Server only: call it from a server action or route handler.
import type { SupabaseClient } from "@supabase/supabase-js";

// Supabase Storage has no "delete folder" call: a folder is just a shared
// path prefix. List what's under it and remove() the files, at most 1000 at a time.
export async function emptyFolder(supabase: SupabaseClient, bucket: string, folder: string) {
  const storage = supabase.storage.from(bucket);
  let removed = 0;

  for (;;) {
    const { data: items, error } = await storage.list(folder, { limit: 1000 });
    if (error) throw error;
    if (items.length === 0) return removed;

    // folders come back with id null: empty them first
    for (const sub of items.filter((item) => item.id === null)) {
      removed += await emptyFolder(supabase, bucket, `${folder}/${sub.name}`);
    }

    const paths = items.filter((item) => item.id !== null).map((item) => `${folder}/${item.name}`);
    if (paths.length === 0) return removed;

    const { data, error: removeError } = await storage.remove(paths);
    if (removeError) throw removeError;
    // fewer deleted than asked: a policy is blocking some files, stop instead of looping
    if (data.length < paths.length) {
      throw new Error(`Deleted ${data.length} of ${paths.length} files in ${folder}. Check your delete and select policies.`);
    }
    removed += data.length;
  }
}

components/hold-stage.tsx (scales the piece to its box)

"use client";
import { useEffect, useState } from "react";

// Hold to Delete draws on a fixed 1000 x 660 stage. This scales it to the
// width of its box and hands back a helper that moves keyboard focus inside.
export function useHoldStage() {
  const [el, setEl] = useState<HTMLDivElement | null>(null);
  const [scale, setScale] = useState(0);
  useEffect(() => {
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setScale(entry.contentRect.width / 1000));
    ro.observe(el);
    return () => ro.disconnect();
  }, [el]);
  const focusIn = (selector: string, delay = 0) =>
    window.setTimeout(() => el?.querySelector<HTMLElement>(selector)?.focus(), delay);
  return { ref: setEl, scale, focusIn };
}

app/files/[name]/file-card.tsx (Hold to Delete as the file)

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import HoldToDelete from "@/components/HoldToDelete";
import { useHoldStage } from "@/components/hold-stage";
import { deleteFile } from "../actions";

type Props = { path: string; name: string; meta: string };

export function FileCard({ path, name, meta }: Props) {
  const router = useRouter();
  const { ref, scale, focusIn } = useHoldStage();
  const [attempt, setAttempt] = useState(0); // a new key puts the file back
  const [message, setMessage] = useState("");
  const ext = name.includes(".") ? name.split(".").pop()!.slice(0, 4).toUpperCase() : "FILE";

  // runs only when the undo window is over: the one call that can't be taken back
  const commit = async () => {
    const { error } = await deleteFile(path);
    if (error) {
      setMessage(`${name} was not deleted: ${error}`);
      setAttempt((n) => n + 1);
      return;
    }
    router.replace("/files");
  };

  return (
    <section className="space-y-3">
      <div ref={ref} className="relative aspect-[1000/660] w-full overflow-hidden rounded-xl">
        {scale > 0 && (
          <HoldToDelete
            key={attempt}
            fileName={name}
            fileMeta={meta}
            fileType={ext}
            tags={[]}
            emptyText="File deleted"
            restoreLabel={null}
            onDelete={() => focusIn(".qhd-toast button", 1100)}
            onUndo={() => focusIn(".qhd-hold")}
            onCommit={commit}
            style={{ position: "absolute", left: 0, top: 0, transform: `scale(${scale})`, transformOrigin: "0 0" }}
          />
        )}
      </div>
      <p role="alert" className="text-sm text-red-600">{message}</p>
    </section>
  );
}

app/files/[name]/page.tsx

import { notFound, redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { FileCard } from "./file-card";
import { FreeDeleteButton } from "../free-delete-button";

// /files/<name>: one file in the user's own folder of the "documents" bucket
export default async function FilePage({ params }: { params: Promise<{ name: string }> }) {
  const { name: raw } = await params;
  const name = decodeURIComponent(raw);
  const supabase = await createClient();
  const { data: auth } = await supabase.auth.getClaims();
  const userId = auth?.claims.sub;
  if (!userId) redirect("/login");

  const { data: files } = await supabase.storage.from("documents").list(userId, { search: name, limit: 100 });
  const file = files?.find((f) => f.name === name && f.id !== null);
  if (!file) notFound();

  const kb = Math.max(1, Math.round(Number(file.metadata?.size ?? 0) / 1024));
  const meta = `${kb} KB · uploaded ${new Date(file.created_at ?? Date.now()).toLocaleDateString("en")}`;

  return (
    <main className="mx-auto max-w-2xl space-y-6 p-6">
      <h1 className="text-xl font-semibold">{name}</h1>
      <p>Hold the button to delete it. You get five seconds to undo before anything is sent.</p>
      <FileCard path={`${userId}/${name}`} name={name} meta={meta} />
      <details className="text-sm">
        <summary>Can&apos;t hold the button?</summary>
        <div className="mt-2">
          <FreeDeleteButton path={`${userId}/${name}`} name={name} />
        </div>
      </details>
    </main>
  );
}

Good to know

  • The action uses the signed-in user's own Supabase client, not a secret key, so your Storage policies decide what each person can delete. Never put the secret key (or the legacy service_role key) in client code. It skips every policy.
  • Check data.length after remove(). An error only means the request failed. A file your policies hide, or a path that isn't there, is simply missing from data, so data.length === 0 is how you spot a blocked delete. The snippet turns that into a message the user can read.
  • Hold to Delete calls onCommit only when the five-second undo window runs out, so remove() never runs for someone who pressed Undo. If the tab closes during that window, nothing is deleted. That's the safe way to fail.
  • The piece is one file card that crumples into a bin, drawn on a 1000 x 660 stage. It fits a file's own page or a preview panel. For a long list of files, use the plain button per row and keep the card for the file someone has open.
  • Holding is slower than clicking, and some people can't hold a key or a touch for a second. That's why the page keeps the plain button under "Can't hold the button?". The piece also gives no spoken progress while held; a screen reader user hears the button's label only.
  • What we ran on Oct 25 2026: every TypeScript file on this page type-checks with TypeScript 5.9.3 against @supabase/supabase-js 2.118.0, @supabase/ssr 0.12.7, next 15.5.28, @types/react 19.3.0, framer-motion 14.6.0 and the shipped HoldToDelete. Three deliberate mistakes fail that check: remove() given a string instead of an array, data used without checking error first, and an onCommit that expects an argument. We didn't call a real Supabase project, so the empty-array behaviour comes from reading the source, not from a live delete. The SQL wasn't run, and we didn't test Safari, Firefox, a real phone or a screen reader.

Why Supabase Storage remove() returns an empty array

  • This is the "remove not working" case: no error, data is [], and the file is still in the bucket. We read the Storage server's source on GitHub on Oct 25 2026. Its delete route locks the paths you sent, asks Postgres which of the existing ones your policies let you delete, deletes only those, and answers 200 with that list. A path that doesn't exist, or one your policies won't let you see or delete, is dropped from the answer without an error.
  • The notes on remove() in storage-js 2.118.0 list what it needs: "objects table permissions: delete and select", and nothing on the buckets table. So a DELETE policy alone isn't enough. If the user can't select the row, they can't delete it either, and you get the same empty array.
  • Two more ways to hit it. Files uploaded from the Dashboard or with the secret key have no owner, per Supabase's Ownership guide ("When using the service_key to create a resource, the owner will not be set"), so an owner_id policy never matches them. And the path is relative to the bucket: "documents/report.pdf" sent to the documents bucket looks for a file named documents/report.pdf, which is usually not the one you meant.

Supabase storage delete policy: the SQL

  • storage-delete-policies.sql above adds a select policy and a delete policy on storage.objects, both limited to the documents bucket and to rows whose owner_id is the signed-in user. The delete policy is the one Supabase's Ownership guide shows, with a bucket check added. owner_id is set to the JWT's sub when a user uploads, so the same user can delete it later.
  • Supabase's Delete Objects page shows the same delete policy written with owner. The Ownership guide says "owner is deprecated and will be removed. Use owner_id instead." That's why the SQL here uses owner_id.
  • If your app stores each user's files under a folder named after their id (the common upload pattern), you can match on (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub') instead, the way Supabase's Access Control guide does for uploads. Whichever you choose, give select and delete the same rule.

Delete through the API, never with SQL

  • Supabase's Delete Objects guide is blunt about this: "Deleting objects should always be done via the Storage API and NOT via a SQL query. Deleting objects via a SQL query will not remove the object from the bucket and will result in the object being orphaned." The Storage schema page adds that the file then becomes "inaccessible, but you'll still be billed for it."
  • So a delete from storage.objects in SQL isn't a real delete. Call remove() from your server, as above.

Supabase storage delete folder

  • There's no delete-folder call. A folder is only a shared path prefix. empty-folder.ts lists what's inside, goes into subfolders first (list() returns them with id null), and removes the files in batches. remove() takes at most 1000 paths per call, per Supabase's Delete Objects guide, and list() returns 100 items unless you pass a limit.
  • It stops with an error when remove() deletes fewer files than it was given, instead of listing the same blocked files forever. For a whole bucket over 200,000 objects, the same guide says the Dashboard and Storage API can't empty it and points to the AWS CLI with Supabase's S3 endpoint.

When a plain button fits better than holding

  • For most apps the free route is enough: free-delete-button.tsx calls the same server action after window.confirm. If you use shadcn, its AlertDialog is the standard version, and our shadcn delete confirmation dialog guide shows one that stays open while the delete runs.
  • Hold to Delete earns its place when the file matters and a misclick would hurt: a contract, an export, the only copy of something. It's a worse fit for a gallery where people clear dozens of images in a row.

Tailwind v4 and shadcn apps

  • In our Oct 25 2026 check of every piece on Tailwind v4, Hold to Delete works as-is with no config change. We re-read its CSS file today: every selector starts with qhd-, both keyframes are named qhd-, and there's no :root, html or body rule, so your shadcn Buttons and theme stay as they are.

Install

  1. Copy from the zipPut react/src/components into src/, then npm i @supabase/supabase-js @supabase/ssr framer-motion.
  2. Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.

Props

onCommit
the undo window ran out: call remove() here
onUndo
Undo was pressed and nothing was sent
fileName / fileMeta / fileType
the object's name, a size and date line, a short label like PDF
emptyText / toastText
the text after the hold
holdMs / undoMs
1200 ms hold and 5000 ms undo by default

How qodesign compares

  • qodesign vs UiverseUiverse is best for: single small elements: buttons, loaders, toggles, checkboxes, inputs.
  • qodesign vs AnimataAnimata is best for: free animated building blocks you copy and adapt.
  • qodesign vs Motion PrimitivesMotion Primitives is best for: small motion building blocks you compose yourself: text effects, number tickers, dialogs, toolbars.

All comparisons, with prices read on each library's own site.

Questions

Why does Supabase Storage remove() return an empty array?

Nothing was deleted. Either the path doesn't exist or your policies don't let this user select and delete that row. remove() doesn't return an error for either case, so check data.length.

What policies does remove() need?

Delete and select on storage.objects, per the notes in storage-js 2.118.0. Nothing on storage.buckets.

Can I delete a Storage file with SQL?

No. Supabase says a SQL delete leaves the file in the bucket, orphaned, and you keep paying for it. Use remove().

How do I delete a folder in Supabase Storage?

List it and remove the files, at most 1000 per call. Subfolders come back from list() with id null; empty those first. empty-folder.ts above does this.

Should I use the service role key to delete?

Only on a server, and only for admin jobs. It skips every policy. For user deletes, the user's own client keeps your policies in charge.

Will it change my shadcn styles?

No. Hold to Delete's CSS only targets its own qhd- classes, with no :root, html or body rules.

Can Cursor or v0 add it for me?

Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.

Can I use it in client work?

Yes, in personal and client projects. You can't resell the code itself as a kit.