Connect profile images to authenticated storage, handle upload failures, and plan replacement and cleanup across separate operations.
An account avatar combines an image file with a profile field that points to it. BTST Better Auth UI can resize an image and update the profile, while your application controls storage, upload authorization, and cleanup.
This guide covers account avatars in @btst/better-auth-ui@2.0.1 with @btst/stack@3.1.2 and better-auth@1.6.16. It assumes the auth and account plugins are already mounted. Organization logos and the Media plugin's library are separate workflows.
The released avatar card crops and resizes the selected file before updating user.image. With no upload callback, it converts the result to a data URL and saves that string. With an upload callback, it saves the URL returned by your storage integration.
A data URL avoids a separate storage service but puts image data into the profile value. A storage URL keeps the account record small and lets you manage delivery and retention separately. Choose deliberately; avatar: true does not automatically upload to Vercel Blob, S3, or BTST Media.
The provider implementation defaults to a 128-pixel PNG without an upload callback and a 256-pixel PNG when a callback is supplied. Specify the size and extension explicitly when defining your integration.
Merge this fragment into the Stack provider's overrides.account. Preserve any other entries in the account field list:
import type { AccountPluginOverrides } from "@btst/better-auth-ui/client";
import { toast } from "sonner";
export const avatarUI = {
account: { fields: ["image", "name"] },
avatar: {
size: 256,
extension: "png",
upload: async (file: File) => {
try {
const body = new FormData();
body.set("file", file);
const response = await fetch("/api/account/avatar", {
method: "POST",
credentials: "same-origin",
body,
});
if (!response.ok) throw new Error("Upload rejected");
const data: unknown = await response.json();
if (!data || typeof data !== "object" ||
!("url" in data) || typeof data.url !== "string" || !data.url) {
throw new Error("Upload response has no URL");
}
return data.url;
} catch {
toast.error("Avatar upload failed. Please try again.");
return null;
}
},
},
} satisfies Partial<AccountPluginOverrides>;
The callback uses Sonner for feedback; keep its toaster mounted or adapt the message to your existing notification component. Returning null stops the profile update and lets this release clear its loading state. The upload call occurs before the card's profile-update try block, so handle expected upload failures inside your callback. Image decoding and resizing are separate steps; this callback does not handle failures that occur before it runs.
POST /api/account/avatar is an application endpoint you must implement. It is not supplied by this snippet or automatically registered by the companion. Its contract is: receive an authenticated multipart upload, store an allowed image owned by the current user, and return { "url": "https://…" } only after storage succeeds. The card performs the subsequent profile update.
The browser's accept="image/*" and resize step improve the upload experience. They do not validate a direct request to your endpoint. Authenticate each upload, enforce the application's origin/CSRF policy, and check the real file format, byte limit, and decoded dimensions on the server. Apply quotas and choose storage keys yourself, using the authenticated owner rather than a client-provided user ID.
Return a URL controlled by your storage configuration. Decide who can read the avatar and how long the URL remains valid. A short-lived signed URL saved permanently in user.image eventually becomes a broken profile image; use a suitable stable delivery path or a design that refreshes it. Keep storage credentials on the server.
If arbitrary profile image URLs are unacceptable, enforce that policy on the profile-update path too. Checking only the upload endpoint leaves a separate authenticated updateUser request able to submit a different image value. Do not treat a stored avatar URL as permission to fetch arbitrary network destinations on your server.
Upload, profile update, and object deletion are separate operations. The pinned card first uploads a replacement, updates the profile, refreshes the session, and then calls the optional avatar.delete callback for the old image. If the profile update fails after an upload, the new object can remain unused. If old-image cleanup fails, the replacement can still be saved.
The explicit Delete action has a different order: it calls the delete callback first, then clears user.image. A storage-delete failure can leave the profile unchanged; a later profile-update failure can leave it pointing at a deleted object. Neither sequence is a transaction across your database and storage.
The example leaves deletion to your storage lifecycle. If you add avatar.delete, authorize it by ownership and resolve a trusted object identifier on the server. Never delete an arbitrary URL supplied by the browser. Account for older OAuth provider images, missing objects, failed profile updates, and unused uploads. A cleanup job or an application-owned coordinated update can be appropriate when immediate deletion is not reliable.
Test a valid image, an oversized direct upload, an anonymous request, an upload failure, and a malformed response. After a successful change, reload account settings and inspect the authoritative profile and stored object. Check cropping on mobile and make sure the delivery URL still works after a later sign-in.
The callback was type-checked and tested with controlled upload responses for success, rejection, network failure, and malformed data. Those checks do not implement or test your storage endpoint, image decoder, cleanup process, or full account UI. Continue with BTST auth setup, custom profile fields, or the separate Media storage guide.