Keel

Docs / Developer tools / File uploads

File uploads

File uploads run on Uploadthing, wired for two things out of the box: user avatars and team logos. The file router is app/api/uploadthing/core.ts; the typed client helper is lib/uploadthing.ts. Like every optional integration in Keel, it is env-gated, without Uploadthing credentials the upload UI is inert and nothing else breaks.

Auth runs before any bytes are accepted

Both endpoints gate in their .middleware(), which runs on Keel's server before Uploadthing accepts the upload, so an unauthenticated or unauthorized caller is rejected up front rather than after a wasted transfer:

ts
teamLogoUploader: f(imageInput)
  .input(z.object({ teamId: z.string().min(1) }))
  .middleware(async ({ input }) => {
    const access = await authorizeTeamAccess(input.teamId, "team:update");
    if (!access.ok) {
      throw new UploadThingError(
        access.status === 403
          ? "You don't have permission to change this team's logo."
          : "Team not found.",
      );
    }
    return { teamId: input.teamId, userId: access.userId };
  })

The avatar endpoint just needs a session (any signed-in user can set their own photo); the logo endpoint runs the full authorizeTeamAccess gate, and mirrors the route-handler convention of returning an opaque "not found" for a non-member so it never confirms a team's existence to an outsider.

onUploadComplete deliberately does not write to the database

Neither endpoint persists the resulting URL. Avatars are saved by the client's existing authClient.updateUser({ image }) call, team logos by PATCH /api/teams/[id]. Keeping one write path per record instead of two means the URL can't drift between an upload hook and the normal update route. onUploadComplete just returns the URL to the browser, which then PATCHes it through the usual path.

The typed client

lib/uploadthing.ts exports useUploadThing, generated from the router's type only:

ts
export const { useUploadThing } = generateReactHelpers<UploadRouter>();

Importing just the type means core.ts's import "server-only" code never leaks into the client bundle, the same trick lib/permissions.ts uses for its TeamRole import.

Constraints and adding endpoints

Both endpoints share imageInput, a single image up to 4MB, and that limit is mirrored in the avatar/logo card copy ("JPG, PNG, or GIF. Up to 4MB."), keep the two in sync if you change it. To add an upload target, define a new key on uploadRouter, gate it in .middleware(), and return the URL from .onUploadComplete() for the client to persist through an existing route.

Edit this page on GitHub →Last updated Jul 22, 2026