Better Auth delete user: turn it on in auth.ts (free)
lib/auth.ts
import { betterAuth } from "better-auth";
import { APIError } from "better-auth/api";
export const auth = betterAuth({
// ...your database, emailAndPassword and socialProviders stay as they are
user: {
deleteUser: {
// Off by default. Without this line the server logs
// "Delete user is disabled. Enable it in the options" and the browser gets a 404.
enabled: true,
beforeDelete: async (user) => {
// Clean up your own rows here, or throw to stop the delete.
if (user.email.endsWith("@yourcompany.com")) {
throw new APIError("BAD_REQUEST", { message: "Staff accounts are removed by an admin." });
}
},
},
},
session: {
// deleteUser() with no password needs a session younger than this.
// 60 * 60 * 24 (one day) is Better Auth's default; 0 turns the check off.
freshAge: 60 * 60 * 24,
},
});
lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
// Same origin as the app, so no baseURL is needed.
export const authClient = createAuthClient();
lib/auth.ts (variant: confirm by email)
import { betterAuth } from "better-auth";
import { sendEmail } from "@/lib/email"; // your own mail helper (Resend, SES, nodemailer...)
// Variant: confirm by email. Needed for people who only sign in with Google or GitHub,
// since they have no password and may not have a fresh session.
export const auth = betterAuth({
user: {
deleteUser: {
enabled: true,
sendDeleteAccountVerification: async ({ user, url }) => {
// url deletes the account when opened in a browser where they're signed in
await sendEmail({ to: user.email, subject: "Confirm account deletion", text: url });
},
},
},
});
app/settings/free-delete-button.tsx (the free route: a plain confirm)
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";
// The free route: Better Auth's own deleteUser behind a plain confirm.
// Swap window.confirm for shadcn's AlertDialog if you use shadcn.
export function FreeDeleteButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
const onClick = async () => {
if (!window.confirm("Delete your account? This can't be undone.")) return;
setPending(true);
const { data, error } = await authClient.deleteUser({ callbackURL: "/goodbye" });
setPending(false);
if (error) {
alert(error.message ?? "Your account was not deleted.");
return;
}
if (data.message === "Verification email sent") {
alert("Check your email to finish deleting your account.");
return;
}
router.replace("/goodbye");
};
return (
<button type="button" disabled={pending} onClick={onClick}>
{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 { authClient } from "@/lib/auth-client";
// verifyByEmail: true when auth.ts sets sendDeleteAccountVerification.
// Then the hold only sends the email; the account goes when they open the link.
export function DeleteAccount({ email, verifyByEmail = false }: { email: string; verifyByEmail?: boolean }) {
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 { data, error } = await authClient.deleteUser({ callbackURL: "/goodbye" });
if (error) {
setMessage(
error.code === "SESSION_EXPIRED"
? "For your safety, sign out and sign back in, then try again."
: `Your account was not deleted: ${error.message ?? error.statusText}`,
);
setAttempt((n) => n + 1);
return;
}
if (data.message === "Verification email sent") {
setMessage(`We sent a link to ${email}. Your account is deleted when you open it.`);
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={verifyByEmail ? "Check your email" : "Account deleted"}
toastText={verifyByEmail ? "Sending the confirmation email" : "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'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 { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { DeleteAccount } from "./delete-account";
export default async function SettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
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={session.user.email} />
</main>
);
}