Skip to main content
All file uploads in RefactKit go through src/server/storage-fns.ts — a server function that runs exclusively on Nitro. This design keeps SUPABASE_SERVICE_ROLE_KEY off the client entirely: the browser never holds credentials, and Supabase Row Level Security policies are never bypassed from untrusted code.

How the upload workflow works

The client collects a file, wraps it in FormData, and calls the uploadImage server function. The server validates the file, uploads it to Supabase Storage using the service role key, and returns the public URL. The client stores that URL in local state and uses it for an instant preview.
Client selects file


Builds FormData { file, bucket }


Calls uploadImage() server function

      ├── Validate: size ≤ 2 MB, content type
      ├── Generate random filename
      ├── Upload ArrayBuffer to Supabase Storage
      └── Return { url: publicUrl }


Client sets state → instant preview via derived state

Set up the avatars bucket

Before your first upload, create the avatars bucket in Supabase. Run this in your Supabase Dashboard → SQL Editor:
-- Create the avatars bucket (public read)
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true)
ON CONFLICT (id) DO NOTHING;

-- Allow public read access for avatar images
CREATE POLICY "Public Access" ON storage.objects
FOR SELECT USING (bucket_id = 'avatars');
For a gallery or any other bucket, replace 'avatars' with your bucket name and adjust the policy as needed.

The uploadImage server function

src/server/storage-fns.ts is the single upload entry point. It accepts a FormData body with two fields — file and bucket:
// 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')

    // Reject files larger than 2 MB
    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}`

    // Convert to ArrayBuffer — Supabase JS handles this on the server
    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 }
  },
)

Call the upload function from a component

Pass a FormData object directly — do not JSON-encode binary data.
import { uploadImage } from '@/server/storage-fns'

async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
  const file = event.target.files?.[0]
  if (!file) return

  const formData = new FormData()
  formData.append('file', file)
  formData.append('bucket', 'avatars')

  const { url } = await uploadImage({ data: formData })
  setUploadedImg(url)
}

Show an instant preview with derived state

After a successful upload, you want to display the new image immediately — before the user saves the form — while still falling back to the existing value from the database. Use a single derived constant rather than two separate state variables:
function AvatarUploader({ defaultValue }: { defaultValue?: string }) {
  const [uploadedImg, setUploadedImg] = useState<string | null>(null)

  // Derived state: new upload takes priority; fall back to saved value
  const currentImg = uploadedImg ?? defaultValue

  return (
    <div className="flex flex-col gap-4">
      {currentImg && (
        <img
          src={currentImg}
          alt="Avatar preview"
          className="size-16 rounded-full object-cover"
        />
      )}
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
    </div>
  )
}
This pattern avoids flicker: currentImg updates the moment setUploadedImg is called, without waiting for a round-trip to the database.

Validation rules

The built-in uploadImage function enforces the following:
RuleValue
Maximum file size2 MB
Content typePassed through from file.type; add explicit checks for stricter control
FilenameRandom alphanumeric string — prevents path traversal and collisions
BucketDefaults to avatars; pass any valid bucket name in FormData
To add content-type validation, insert a check before the upload call:
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
  throw new Error('Only JPEG, PNG, and WebP files are allowed')
}
SUPABASE_SERVICE_ROLE_KEY bypasses Supabase Row Level Security entirely. Never prefix it with VITE_ and never reference it from any file that is part of the client bundle. It must only be used inside server functions in src/server/ or server-only library files like lib/supabase.ts.