BTST
PluginsQuickstartDocs
Live Blog

From research to product evaluation

Evaluating a publishing workflow for an app you already own?

See what the BTST Blog plugin adds to an existing React or Next.js app
BTST

Open-source TypeScript features for the React application, data, and deployment you already own.

Released plugins

  • Blog
  • AI Chat
  • CMS
  • Form Builder
  • UI Builder
  • Kanban
  • Comments
  • Media
  • Route Docs
  • OpenAPI
  • Better Auth UI

Resources

  • Quickstart
  • Documentation
  • All plugins
  • Live Blog
  • GitHub (opens in a new tab)
© 2026 BTST. Open source under the MIT License.
AI Chat
September 21, 2026ReactBetter Auth UI

Upload Account Avatars with BTST Better Auth UI

Connect profile images to authenticated storage, handle upload failures, and plan replacement and cleanup across separate operations.

Upload Account Avatars with BTST Better Auth UI

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.

Choose where the image lives#

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.

Supply an upload callback#

Merge this fragment into the Stack provider's overrides.account. Preserve any other entries in the account field list:

TS
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
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.

Enforce storage rules on the server#

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.

Plan replacement and deletion together#

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.

Verify the visible and stored result#

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.

In This Post

Choose where the image livesSupply an upload callbackEnforce storage rules on the serverPlan replacement and deletion togetherVerify the visible and stored result