Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.refactkit.com/llms.txt

Use this file to discover all available pages before exploring further.

RefactKit stores avatars, organization logos, and gallery images in Supabase Storage. The upload workflow is deliberately server-side: the browser never touches Supabase directly, and the SUPABASE_SERVICE_ROLE_KEY never leaves the server. A dedicated uploadImage server function in src/server/storage-fns.ts acts as the secure intermediary between your UI and Supabase.

How the secure upload workflow works

1

Client sends FormData to the server

The ImageUpload component builds a FormData object containing the file and the target bucket name, then calls the uploadImage server function directly — no manual HTTP request needed.
2

Server validates and uploads the file

The uploadImage server function receives the FormData, enforces a 2 MB size limit, generates a random filename, converts the file to an ArrayBuffer, and uploads it to Supabase using the service role key:
// src/server/storage-fns.ts
import { createServerFn } from '@tanstack/react-start'
import { supabase } from '@/lib/supabase'

export const uploadImage = createServerFn({ method: 'POST' }).handler(
  async ({ data }: { data: FormData }) => {
    if (!supabase) {
      throw new Error('Supabase client not initialized. Check your environment variables.')
    }

    const file = data.get('file') as File
    const bucket = (data.get('bucket') as string) || 'avatars'

    if (!file) throw new Error('No file provided')

    // Basic size validation (2MB)
    if (file.size > 2 * 1024 * 1024) {
      throw new Error('File size exceeds 2MB limit')
    }

    const fileExt = file.name.split('.').pop()
    const fileName = `${Math.random().toString(36).substring(2)}.${fileExt}`

    const arrayBuffer = await file.arrayBuffer()

    const { error: uploadError } = await supabase.storage
      .from(bucket)
      .upload(fileName, arrayBuffer, {
        contentType: file.type,
        upsert: true,
      })

    if (uploadError) {
      throw new Error(`Upload failed: ${uploadError.message}`)
    }

    const {
      data: { publicUrl },
    } = supabase.storage.from(bucket).getPublicUrl(fileName)

    return { url: publicUrl }
  },
)
3

Server returns the public URL

The function returns { url: publicUrl }. Your component receives this URL and can use it immediately — no presigned URLs, no CORS configuration needed.

Invalidate caches after a successful upload

When a user updates their avatar or organization logo, stale data in TanStack Query and TanStack Router’s loader cache will show the old image until they refresh the page. Invalidate both caches inside onSuccess:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRouter } from '@tanstack/react-router'

const queryClient = useQueryClient()
const router = useRouter()

const { mutate } = useMutation({
  mutationFn: async (formData: FormData) => {
    return uploadImage({ data: formData })
  },
  onSuccess: () => {
    // 1. Invalidate TanStack Query caches
    queryClient.invalidateQueries({ queryKey: ['user-orgs'] })
    // 2. Invalidate TanStack Router loaders (refreshes session and global state)
    router.invalidate()
  },
})
queryClient.invalidateQueries marks cached data as stale so the next useQuery call re-fetches. router.invalidate() re-runs the active route loaders, which is how the sidebar and navbar pick up the new logo without a full page reload.

Prevent UI flickering with the Derived State Pattern

After a successful upload, there is a brief moment between the mutation completing and the re-fetched query resolving. Without handling this, the UI can flash back to the old image. The Derived State Pattern eliminates this flicker by giving locally uploaded images priority over the cached value:
const [uploadedImg, setUploadedImg] = useState<string | undefined>()
const currentImg = uploadedImg || defaultValue
// defaultValue comes from TanStack Query / the route loader
Pass currentImg to the <img> element and update uploadedImg inside onSuccess. The component displays the new image immediately — before the query re-fetch completes — because uploadedImg takes precedence in the derived expression.
const { mutate } = useMutation({
  mutationFn: async (formData: FormData) => uploadImage({ data: formData }),
  onSuccess: ({ url }) => {
    setUploadedImg(url) // instant UI update
    queryClient.invalidateQueries({ queryKey: ['user-orgs'] })
    router.invalidate()
  },
})

Set up Supabase Storage

1

Configure environment variables

Add these two variables to your .env file:
VITE_SUPABASE_URL="https://your-project-id.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="eyJhbGci...your_long_service_role_key"
VITE_SUPABASE_URL is safe to expose to the browser (it is the public project URL). SUPABASE_SERVICE_ROLE_KEY must never be sent to the client — keep it server-only.
2

Create the avatars bucket

Run the following SQL in your Supabase SQL Editor to create the bucket and configure public read access:
-- 1. Create the "avatars" bucket with public read
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;

-- 2. Allow anyone to read files from this bucket
create policy "Public Access"
on storage.objects for select
using ( bucket_id = 'avatars' );

-- Note: server functions use the Service Role Key, which bypasses RLS.
-- No INSERT/UPDATE policies are needed for server-side uploads.
Never import SUPABASE_SERVICE_ROLE_KEY in any file that is bundled for the browser. Only use it inside server functions (files that call createServerFn). TanStack Start and Nitro enforce the server/client boundary, but a misconfigured import can still leak the key into the client bundle.

Use a custom bucket

The uploadImage server function reads the bucket name from the FormData payload, defaulting to avatars. To upload to a different bucket, set the bucket field when building the FormData:
const formData = new FormData()
formData.append('file', selectedFile)
formData.append('bucket', 'gallery')

const { url } = await uploadImage({ data: formData })
Create the new bucket in Supabase and add the appropriate storage policies before using it.

File size and type constraints

The uploadImage function enforces a hard 2 MB limit server-side. You should also validate on the client before calling the server to provide faster feedback:
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0]
  if (!file) return

  if (file.size > 2 * 1024 * 1024) {
    toast.error('File must be smaller than 2 MB')
    return
  }

  const formData = new FormData()
  formData.append('file', file)
  mutate(formData)
}