Skip to main content
Docs
Build with DeepSpace

File uploads

Let users upload files (avatars, attachments, generated images) to R2.

DeepSpace apps get a per-app R2 bucket out of the box. The useR2Files hook covers uploads, listings, deletions, and authenticated downloads - all proxied through the platform-worker so the app never holds R2 credentials directly.

This guide shows the common end-to-end flows. For the full method signatures and types, see the files reference.

How files are wired#

You don't add an R2 binding yourself. The starter worker already proxies /api/files/* to the platform-worker, which holds a shared bucket and namespaces keys per app (via the APP_NAME the worker forwards on each request). Every write is gated by the caller's signed JWT.

The client side is a single hook:

import { useR2Files } from 'deepspace'

Upload from a file input#

The most common case - an <input type="file"> or drag-drop event. Pass the resulting File to upload:

import { useR2Files } from 'deepspace'

function FileUploader() {
  const { upload, isUploading } = useR2Files()

  async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file) return
    const result = await upload(file, file.name)
    if (result.success) {
      console.log('uploaded:', result.key)
    } else {
      console.error(result.error)
    }
  }

  return (
    <input type="file" onChange={onFileChange} disabled={isUploading} />
  )
}

Always check result.success before reading result.key - the upload may fail (network, auth). isUploading is true while a request is in flight.

Upload generated data (canvas, cropped image)#

When you have data as a Base64 string - for example, from <canvas>.toDataURL() - use uploadBase64. The display name is required so the file has an originalName for later downloads:

const { uploadBase64 } = useR2Files()

async function saveCanvasAsImage(canvas: HTMLCanvasElement) {
  const dataUrl = canvas.toDataURL('image/png')
  const base64 = dataUrl.split(',')[1]
  const result = await uploadBase64(base64, 'drawing.png', 'image/png')
  if (!result.success) console.error(result.error)
}

List and render a user's files#

list() is an async function - call it and store the result in state rather than reading a reactive array:

import { useState, useEffect } from 'react'
import { useR2Files, formatFileSize } from 'deepspace'
import type { R2FileInfo } from 'deepspace'

function Gallery() {
  const { deleteFile, list, getUrl } = useR2Files()
  const [files, setFiles] = useState<R2FileInfo[]>([])

  async function refresh() {
    setFiles(await list())
  }

  useEffect(() => { refresh() }, [])

  return (
    <div>
      {files.map((f) => (
        <div key={f.key}>
          <img src={getUrl(f)} alt="" />
          <p>{f.originalName ?? f.key} - {formatFileSize(f.size)}</p>
          <button onClick={async () => { await deleteFile(f); refresh() }}>
            Delete
          </button>
        </div>
      ))}
    </div>
  )
}

Re-call list() after mutations - there's no reactive cache. formatFileSize and isImageFile are display helpers exported from deepspace.

Authenticated downloads#

getUrl(fileOrKey) returns a plain URL with no auth token attached. It works for unauthenticated reads on deployed sites (the platform-worker resolves the app from APP_NAME and serves reads without a JWT), which is what you want for <img src>. For everything else, use downloadFile or readFile:

const { downloadFile, readFile } = useR2Files()

// Trigger a Save As… dialog. Uses originalName as the filename automatically.
const result = await downloadFile(file)
if (!result.success) console.error(result.error)

// Or read the bytes yourself - returns a Response you can .text(), .blob(), etc.
const resp = await readFile(file)
const text = await resp.text()

Both accept either an R2FileInfo from list() or a raw key string.

Storing metadata (MIME type, captions, tags)#

R2FileInfo carries key, size, uploaded, url, originalName, and uploadedBy - and nothing else. There's no mimeType field. For richer metadata, store a sidecar record in a collection:

// src/schemas/attachments-schema.ts
import type { CollectionSchema } from 'deepspace'

export const attachmentsSchema: CollectionSchema = {
  name: 'attachments',
  columns: [
    { name: 'fileKey', storage: 'text', interpretation: 'plain' },
    { name: 'mimeType', storage: 'text', interpretation: 'plain' },
    { name: 'caption', storage: 'text', interpretation: 'plain' },
  ],
  permissions: {
    member: { read: true, create: true, update: 'own', delete: 'own' },
  },
}

Create the sidecar alongside the upload. The snippet assumes a RecordProvider higher in the tree that has registered the attachments collection - useMutations throws otherwise.

import { useR2Files, useMutations } from 'deepspace'

type Attachment = { fileKey: string; mimeType: string; caption: string }

const { upload } = useR2Files()
const { create } = useMutations<Attachment>('attachments')

async function uploadWithMeta(file: File) {
  const result = await upload(file, file.name)
  if (!result.success || !result.key) {
    console.error(result.error)
    return
  }
  await create({
    fileKey: result.key,
    mimeType: file.type,
    caption: '',
  })
}

Scoping and permissions#

useR2Files is always scoped to the current app - the platform derives the bucket prefix from APP_NAME. There's no per-user or cross-app scope exposed by the hook. If you need finer namespacing (per-room, per-project), encode it into the name argument at upload time or store it on a sidecar record.

Writes require a signed user JWT. Anyone who knows a file's key and hits your app's /api/files/<key> can read it; if a file should be private, gate access in your UI and use readFile/downloadFile rather than rendering its URL. There is no recycle bin - deleteFile is immediate and irreversible.

Local development#

For tests written before the first deploy, assert that uploads are dispatched (the function is called) rather than asserting on the round-trip. Or run npx deepspace dev --prod to point local UI at production workers.

Next steps#

  • Files reference - full method signatures, return types, and the R2FileInfo shape.
  • Custom bindings - declare a wholly separate R2 bucket with custom permissions.
  • Data model - pair files with sidecar records for queryable metadata.