Supabase delete user from Next.js, with a Delete account button

Supabase deletes a user with auth.admin.deleteUser(). It's free and it's the whole backend: one server action that runs with your secret key. Any button can call it, and a plain confirm dialog is the usual pattern. If you'd rather have a button that can't fire on a misclick, Hold to Delete asks for a 1.2 second hold and gives five seconds to undo before the server action runs.

Hold to Delete, preview
One payment$3

By buying you agree to the Terms.

Ready forCursorv0Lovableshadcn CLI

Full product pageReact · Tailwind · TypeScript

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

app/settings/actions.ts

"use server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";

export async function deleteAccount(): Promise<{ error?: string }> {
  const supabase = await createClient();

  // who is asking: verified claims from the session cookie, never an id from the browser
  const { data, error } = await supabase.auth.getClaims();
  const userId = data?.claims.sub;
  if (error || !userId) return { error: "You're signed out. Sign in and try again." };

  // the delete itself: Supabase Auth's admin API, server side only
  const admin = createAdminClient();
  const { error: deleteError } = await admin.auth.admin.deleteUser(userId);
  if (deleteError) {
    // e.g. the user still owns files in Supabase Storage
    return { error: deleteError.message };
  }

  // clear this browser's session cookies (a 404 for the deleted user is ignored)
  await supabase.auth.signOut();
  return {};
}

lib/supabase/admin.ts (secret key, server only)

import { createClient } from "@supabase/supabase-js";

// Server only. SUPABASE_SECRET_KEY (sb_secret_...) or the legacy service_role key.
// Never import this file from a "use client" file, and never prefix the key with NEXT_PUBLIC_.
export function createAdminClient() {
  return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, {
    auth: { autoRefreshToken: false, persistSession: false },
  });
}

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/settings/free-delete-button.tsx (the free route: a plain confirm)

"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteAccount } 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() {
  const router = useRouter();
  const [pending, startTransition] = useTransition();
  return (
    <button
      type="button"
      disabled={pending}
      onClick={() => {
        if (!window.confirm("Delete your account? This can't be undone.")) return;
        startTransition(async () => {
          const { error } = await deleteAccount();
          if (error) alert(error);
          else router.replace("/goodbye");
        });
      }}
    >
      {pending ? "Deleting…" : "Delete my account"}
    </button>
  );
}

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/settings/delete-account.tsx (Hold to Delete as the button)

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

export function DeleteAccount({ email }: { email: string }) {
  const router = useRouter();
  const { ref, scale, focusIn } = useHoldStage();
  const [attempt, setAttempt] = useState(0); // a new key resets the piece
  const [message, setMessage] = useState("");
  const [typed, setTyped] = useState("");

  const commit = async () => {
    const { error } = await deleteAccount();
    if (error) {
      setMessage(`Your account was not deleted: ${error}`);
      setAttempt((n) => n + 1);
      return;
    }
    router.replace("/goodbye");
  };

  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={email}
            fileMeta="Your account, profile and saved data"
            fileType="YOU"
            tags={[]}
            emptyText="Account deleted"
            toastText="Deleting your account"
            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>

      {/* holding is hard for some people: the same action behind a typed word */}
      <details className="text-sm">
        <summary>Can&apos;t hold the button?</summary>
        <form
          className="mt-2 flex gap-2"
          onSubmit={(e) => {
            e.preventDefault();
            if (typed === "DELETE") void commit();
          }}
        >
          <input
            value={typed}
            onChange={(e) => setTyped(e.target.value)}
            aria-label="Type DELETE to confirm"
            placeholder="Type DELETE"
            className="rounded border px-2 py-1"
          />
          <button type="submit" disabled={typed !== "DELETE"} className="rounded bg-red-600 px-3 py-1 text-white disabled:opacity-50">
            Delete my account
          </button>
        </form>
      </details>
    </section>
  );
}

app/settings/page.tsx

import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { DeleteAccount } from "./delete-account";

export default async function SettingsPage() {
  const supabase = await createClient();
  const { data } = await supabase.auth.getClaims();
  if (!data) redirect("/login");

  return (
    <main className="mx-auto max-w-2xl space-y-6 p-6">
      <h1 className="text-xl font-semibold">Delete account</h1>
      <p>Hold the button. You get five seconds to undo before anything is sent.</p>
      <DeleteAccount email={String(data.claims.email ?? "your account")} />
    </main>
  );
}

Good to know

  • Hold to Delete calls onCommit only when the five-second undo window runs out, so the server action 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 drawn as a file card that crumples into a bin. For an account, the snippet puts the email where the file name goes and "YOU" on the file icon. If a file going into a bin feels wrong for your product, use the plain confirm above instead.
  • A server action is a public endpoint, so deleteAccount() never takes a user id from the browser. It reads the id from getClaims(), which Supabase's SSR guide says verifies the token's signature, so a request can only delete the account it's signed in as.
  • If deleteUser() fails, the snippet shows the reason and gives the piece a new key, which puts the button back. The commonest failure the docs name is a user who still owns files in Storage.
  • Holding is slower than clicking, and some people can't hold a key or a touch for a second. That's why the snippet keeps a typed DELETE form 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.
  • On a 390 px phone the piece scales to about 370 px wide, so the hold button is roughly 178 by 36 px. That's usable, but the typed form is the easier path there.
  • What we ran on Oct 25 2026: every 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, and a deliberately wrong deleteUser call fails the same check. We then rendered delete-account.tsx headless in Chromium at 1440 and 390 px with React 19.3.1, with the server action swapped for a stub whose first call returned an error and second call succeeded. Holding Space deleted on release, focus moved to Undo, the error showed and the button came back, the second hold redirected, and Undo sent nothing. We didn't call a real Supabase project, didn't run next build on these files, and didn't test Safari, Firefox, a real phone or a screen reader.

What Supabase's docs say about deleting a user

  • We read the supabase-js reference on Oct 25 2026. The auth.admin.deleteUser page opens with "Delete a user. Requires a service_role key." and warns: "This function should only be called on a server. Never expose your `service_role` key in the browser." The admin API overview on the same day says "Any method under the supabase.auth.admin namespace requires a secret key."
  • Those two pages agree once you read the API keys guide: "Supabase is deprecating the anon and service_role keys by the end of 2026. Use the publishable (sb_publishable_xxx) and secret (sb_secret_xxx) keys instead." The snippet reads SUPABASE_SECRET_KEY. A legacy service_role key works in the same place until it's retired.
  • deleteUser takes a second argument, shouldSoftDelete. The reference says it defaults to false and that "Soft deletion allows user identification from the hashed user ID but is not reversible." The snippet leaves it at the default, which removes the row from auth.users.
  • The admin client is created the way the admin API page shows it, with autoRefreshToken and persistSession turned off, since it never holds a user session.

Supabase delete user: sessions, rows and Storage

  • Supabase's User Management guide says: "Note that deleting a user from the auth.users table does not automatically sign out a user." Its section on removing account access adds that a hard delete "cascades to auth.sessions and invalidates the user's refresh tokens", but "a token already in the user's hands stays valid until its exp claim passes". It offers two ways to handle that window: keep the access token expiry short, or check the session_id claim against auth.sessions on sensitive requests.
  • The snippet also calls signOut() right after the delete so this browser drops its cookies. We read supabase-js 2.118.0's source for that call: it ignores a 404, 401 or 403 from the server ("ignore 404s since user might not exist anymore") and still removes the local session.
  • Your own tables are a separate question. The guide's profiles example references auth.users with on delete cascade, so a profile row goes with the user. Any table that points at the user without that clause needs its own cleanup, or the delete will fail on the foreign key.
  • Storage is the one the guide calls out: "You cannot delete a user if they are the owner of any objects in Supabase Storage." Delete their files first, or reassign them. The snippet shows the error message and resets the button if this happens.

When a plain dialog fits better than holding

  • For most apps the free route is enough: free-delete-button.tsx above calls the same server action after window.confirm. If you use shadcn, its AlertDialog is the standard version of that, and /react/shadcn-delete-confirmation-dialog shows one that stays open while the delete runs.
  • Hold to Delete earns its place when the delete is permanent and a misclick would hurt, which is usually the case for a whole account. It's a worse fit for things people delete many times a day, or for a team that wants every dangerous action to look the same.

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. Its CSS file only targets classes that start with qhd-. We re-read it today: there's no :root, html or body rule, and the few element rules (the toast's button, the hold button's svg) sit under the piece's own classes, 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 the server action here
onUndo
Undo was pressed and nothing was sent
fileName / fileMeta / fileType
what the card shows: the email, one line, a short label
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

Can I call deleteUser from the browser?

No. It needs the secret key (or the legacy service_role key), and Supabase's reference says to call it only on a server. Put it in a server action or route handler, as above.

Does deleting the user sign them out everywhere?

Their refresh tokens stop working, so no device can get a new access token. An access token that was already issued stays valid until it expires, per Supabase's guide. Keep the JWT expiry short if that window matters.

Will deleting the auth user delete their rows?

Only rows whose foreign key to auth.users says on delete cascade. Everything else needs its own delete, before or after the user goes.

Why did deleteUser fail?

The case Supabase's guide names is a user who still owns Storage objects. Delete or reassign their files first. The snippet shows the error text and puts the button back.

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.