Skip to main content
Documentation

File uploads

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

On this page

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'
ts

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} />
  )
}
tsx

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)
}
tsx

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>
  )
}
tsx

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()
ts

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' },
  },
}
ts

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: '',
  })
}
tsx

Scoping and permissions#

useR2Files takes a scope, and the scope decides who can read the file:

const { upload } = useR2Files()                  // 'self' - per-user, auth-gated reads
const { upload } = useR2Files({ scope: 'app' })  // app-wide, PUBLIC reads
tsx
ScopePrefixReads
'self' (default)apps/<app>/users/<userId>/…Require the caller's auth token
'app'apps/<app>/…Public - the URL works directly as an <img src>

Both scopes are per-app; the platform derives the prefix server-side, so a key can never address another app. For finer namespacing (per-room, per-project), encode it into the key at upload time or store it on a sidecar record.

Writes always require a signed-in user, under either scope. There is no recycle bin - deleteFile is immediate and irreversible.

Large files and media#

Two different ceilings apply depending on how an asset reaches production.

File size and storage limits#

A file can be up to 1 GiB. One HTTP request carries at most 25 MiB, so both useR2Files and deepspace app files put automatically upload anything above 20 MiB in parts — no flag, no size math on your side.

The server also refuses content types it would execute as active content (HTML, SVG, JS). The refusal is based on the declared type (from the file extension or the type your code supplies), so renaming a file can get its bytes stored — but not executed: every stored file is served with its stored Content-Type and X-Content-Type-Options: nosniff, so browsers never sniff a disguised file into running in your app's origin. The serving headers are the guarantee; the upload refusal is the early warning.

Your app's whole file allocation is capped by the owner's plan — 128 MiB on the free plan, more on paid ones. The limit binds wherever bytes are written: your app's own uploads at runtime and your deepspace app files put from the CLI spend the same allocation, always against the owner's plan whoever is uploading. An upload that would cross it is refused with storage_quota_exceeded (HTTP 409) naming what is used and what the limit is, and nothing partial is stored. Replacing a file at an existing key is only charged for the bytes it adds, so swapping a large file for a smaller one always works even when you are at the ceiling.

npx deepspace app files list
# KEY              SIZE  UPLOADED
# hero.jpg      2.4 MiB  2026-08-06
# Storage: 2.4 MiB of 128.0 MiB used
bash

Deploy assets are a separate allocation — a full file allocation never blocks a deploy, and vice versa.

Publishing assets as the owner: deepspace app files#

If you (not an end user) need to publish an image or a media file, you can push it straight into the app's files allocation from the command line - no deploy, no commit:

npx deepspace app files put logo.png
npx deepspace app files put hero.jpg --key img/hero.jpg
npx deepspace app files list --prefix img/
npx deepspace app files get img/hero.jpg --out ./hero.jpg
npx deepspace app files rm img/hero.jpg
bash

Keys you pass are relative to the app (logo.png). The SERVING URL is not: it carries the physical prefix the platform owns, so it is /api/files/apps/<resourceId>/<key>?scope=app. Requesting /api/files/logo.png?scope=app does not resolve.

Do not assemble it by hand — deepspace app files put prints the exact URL, and returns it as path under --json:

✓ logo.png (18.2 KiB)
  Served from your app at /api/files/apps/app_01H…/logo.png?scope=app

This reaches the same app-scoped storage as useR2Files({ scope: 'app' }), but as the app owner rather than as an end user.

Don't commit large media to Git#

The cloud repo enforces 20 MiB per object and 32 MiB of compressed history per push. An oversized push is refused with push_too_large, and deploy reports the same rejection.

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.

Next steps#