Skip to content
R2 files & uploads
FrameworkR2 files & uploads
On this page

R2 files & uploads

Store public media and private files, upload from React forms, and issue signed download links.

Available in Goribu 0.9.0. Managed R2 includes bucket configuration, storage APIs, signed downloads, and browser uploads.

Configure buckets

Declare a default bucket and, optionally, a second private bucket. Goribu creates or reuses them during deployment and supplies R2 and R2_PRIVATE bindings.

goribu.config.js
import { defineConfig } from "goribu/server";

export default defineConfig({
  name: "my-app",
  compatibilityDate: "2026-09-01",
  r2: {
    name: "my-app-media",
    domain: "media.example.com",
    private: { name: "my-app-files" },
    // jurisdiction: "eu", // Optional; applies to both buckets.
  },
});

domain makes the default bucket public through that hostname. Use a separate media hostname in an active Cloudflare zone in your account. Supply only the hostname, without a scheme, port or path. Goribu connects it during deployment; DNS and certificate activation may take time.

For private storage only, use r2: { name: "my-app-files" } and access it through req.r2. The optional private bucket requires a public default bucket. Bucket names must be distinct, use 3–63 lowercase letters, digits or hyphens, and start and end with a letter or digit. Omit jurisdiction for default storage or use "eu" for both buckets.

Configure R2 only through goribu.config.js. At most two managed buckets are supported; handwritten Wrangler R2 declarations are rejected.

Upload from a form

Declare uploads on the receiving route, then use FileUpload inside Form. Protect the route with middleware that checks the user's authorization; it runs before upload starts and again when the form is submitted.

src/routes/profile.jsx
import { Form, FileUpload } from "goribu";

export const uploads = {
  avatar: { accept: ["image/*"], maxBytes: 5_000_000 },
};

export async function POST(req, res) {
  const { avatar } = await req.body();
  if (!avatar) return res.invalid({ avatar: "Upload an image." });
  // Save avatar.key with the user's record here.
  return res.json({ url: avatar.url });
}

export default function Profile() {
  return (
    <Form method="post">
      {({ pending, uploading, errors, data }) => <>
        <FileUpload name="avatar" />
        {errors?.avatar && <p>{errors.avatar}</p>}
        {errors?.root && <p>{errors.root}</p>}
        {uploading && <p>Your file is uploading.</p>}
        <button disabled={pending}>
          {pending ? "Saving…" : "Save"}
        </button>
        {data?.url && <img src={data.url} alt="Saved avatar" />}
      </>}
    </Form>
  );
}

Picking a file starts the transfer immediately. By default the bytes stream through the Worker into R2; direct: true sends them straight to R2 in production. Save waits for unfinished transfers, then submits signed references alongside the other form fields. It does not send the file bytes again.

await req.body() verifies each reference against the stored object and returns { key, name, type, size, url? }. The public default bucket includes url. Invalid, expired or failed uploads leave the field absent, so return res.invalid() when the file is required. Save the verified key with your application record.

uploading reports active transfers; pending reports submission, including its wait for uploads. Managed pickers have no input name, so the form stays URL-encoded unless it also contains an ordinary named file input. These controls require JavaScript. See Forms for submission and reset behavior.

Policies and verification

Policies on the receiving route
export const uploads = {
  avatar: {
    accept: ["image/jpeg", "image/png"],
    maxBytes: 5_000_000,
    key: ({ ext }) => "avatars/" + crypto.randomUUID() + ext,
  },
  document: {
    accept: ["application/pdf"],
    maxBytes: 20_000_000,
    private: true,
  },
};

Each field requires accept MIME types and maxBytes, a positive integer. A subtype wildcard such as image/* is allowed; */* is not. The Worker limit is 100,000,000 bytes; direct policies allow up to 5,000,000,000 bytes. private: true stores the field in the second bucket, r2.private, and requires it. It does not make a field private on its own: with a single bucket and no domain, every upload is already private, so leave private out.

The optional synchronous key function receives { req, file, ext } after middleware. Otherwise Goribu uses uploads/<uuid><ext>. Always choose a fresh key: uploads refuse to overwrite existing objects. A fixed avatar key makes the next upload fail. To replace a file, save the verified new key first, then explicitly delete the old object.

A route exporting policies must also export POST, PUT or PATCH. Its policies and key function remain server-only. That route runs on the Worker; forcing prerender = true fails the build. A static page can instead post to a separate action route containing the policies and handler. In that case the picker has no policy-derived type filter or instant size check; the action still enforces both.

Transfer targets last 60 seconds; references last one hour. References bind the route pattern, field, bucket and upload identity, but not a user ID or a particular dynamic parameter. Authorize access in your app, keep refs and signed URLs private, and handle repeated submissions deliberately.

MIME types come from the browser and do not prove the file's contents. Keep files needing moderation private. Public uploads receive Cache-Control: public, max-age=31536000, immutable; add a X-Content-Type-Options: nosniff response header rule on the media domain.

Custom upload UI

Preview and drop target
<FileUpload name="avatar">
  {({ file, previewUrl, status, errorMessage, showPicker, dropProps }) => (
    <div {...dropProps}>
      {previewUrl && <img src={previewUrl} alt="Selected image" />}
      <button type="button" onClick={showPicker}>
        {file ? file.name : "Choose or drop an image"}
      </button>
      {status === "uploading" && <p>Uploading…</p>}
      {errorMessage && <p>{errorMessage}</p>}
    </div>
  )}
</FileUpload>

FileUpload takes name and an optional render child. Each field holds one file; use distinct names for multiple fields. The render child receives file, previewUrl, status, errorMessage, showPicker and dropProps. Status is idle, uploading, done or error. Call showPicker() from a user gesture.

A new pick cancels the previous attempt. Reset and unmount clear the ref, abort pending work and revoke the image preview URL. Reset does not delete a file already stored in R2. Abandoned uploads have no automatic cleanup; configure an R2 lifecycle rule for an appropriate upload prefix if your app needs it.

Upload without a form

Use upload() in a custom drop, paste or file-selection handler. Its action must be a same-origin route declaring the named policy. Submit the returned ref to that action to obtain the verified file.

Custom upload handler
import { upload, UploadError } from "goribu";

async function saveImage(file, signal) {
  try {
    const ref = await upload(file, {
      action: "/profile/save",
      name: "avatar",
      signal,
    });
    return await fetch("/profile/save", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ avatar: ref }),
      signal,
    });
  } catch (error) {
    if (signal?.aborted) return;
    if (error instanceof UploadError) {
      // Display error.message in your UI.
    }
    throw error;
  }
}

UploadError.message is ready to display to the user. Its code is type, size, forbidden, network, expired or server. Cancellation also reports network; check the signal to suppress cancellation UI. There is no automatic retry. Call upload() again for a fresh target.

Read, write and download

Handlers and middleware receive req.r2 and, when configured, req.r2.private. Both expose native head, get, put, delete, list, createMultipartUpload and synchronous resumeMultipartUpload. Options and results follow the Workers R2 API. A File or Blob supplies its content type on put when you have not set one explicitly.

Server-side storage and links
// In an authorized handler, after checking access to the record:
await req.r2.put("exports/latest.csv", csvBlob);
const publicUrl = req.r2.url("exports/latest.csv");

const downloadUrl = await req.r2.private.signedUrl(document.key, {
  expiresIn: 900,
  download: "report.pdf",
});

url(key) requires the public bucket and encodes each path segment. Development uses a local public-file endpoint. signedUrl(key, options?) works on either bucket. Its lifetime defaults to 900 seconds and accepts 1–604800 seconds. Omit download for inline delivery, or supply an attachment filename.

Available in Goribu 0.9.1. For images or other files whose bytes never change under the same key, pass immutable: true to reuse links and allow private browser caching until they expire:

A cacheable signed link
// Authorize this user first. Replacing the image must create a new object key.
const imageUrl = await req.r2.private.signedUrl(image.fileKey, {
  expiresIn: 604800,
  immutable: true,
});

immutable defaults to false. With true, URLs stay identical within windows of half the requested lifetime for the same key, bucket, and options, and each link still has at least half its lifetime left when issued; a seven-day lifetime rotates URLs about every 3.5 days. Responses allow private browser caching bounded by link expiry, not shared CDN caching. Cached bytes cannot be revoked by logout, permission changes, deletion, or secret rotation, so keep the default when that is unsuitable.

To serve a private file at a stable app URL instead of a signed link, check access in a route and return its body with res.stream(), available since 0.9.2.

Without R2 API keys, signed downloads use the Worker. With both keys in production, they use R2's S3 endpoint, including its EU endpoint for EU buckets. Development always uses the Worker. A partial key pair fails explicitly.

Worker downloads support HEAD, single byte ranges and conditional requests. They are sandboxed with Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, so uploaded HTML or SVG cannot run scripts as the app. Inline PDF viewing and attachment downloads were verified in Chrome 154 on macOS.

Issue signed links at request time after checking access. The Worker download endpoint under /_goribu/ does not run your middleware, so a signed link is a bearer token until it expires. Links cannot be baked into prerendered HTML. Rotating GORIBU_SECRET invalidates Worker download links and upload refs; presigned downloads use the separate R2 credentials.

Direct uploads

A larger private upload
export const uploads = {
  video: {
    accept: ["video/mp4"],
    maxBytes: 500_000_000,
    direct: true,
    private: true,
  },
};

Add this policy to a route with a receiving handler. Production sends the bytes straight from the browser to R2. Development still uses local Worker storage. Both paths verify the resulting object when you submit its ref and use conditional writes to prevent overwrites.

Direct uploads need an R2 API token stored as the Worker secrets R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY. Since 0.9.2 you do not set them up before deploying. When either is missing, pnpm check lists it, and pnpm run deploy run from a terminal sets them up on the way: it creates the upload buckets, prints a link to the account's R2 API tokens page with the buckets to scope a new Object Read & Write token to, then asks for each missing value through Wrangler's hidden prompt before uploading the Worker.

Goribu checks secret names, never values, so keys declared only in .env.production still lead to the prompt. Cancelling a prompt stops deploy before the Worker upload; run it again to be asked for what is still missing. A deploy without a terminal, such as in CI, stops before any change and asks you to run it once from a terminal. Later deploys reuse the stored secrets.

Deploy manages a goribu-uploads CORS rule only on buckets with direct fields, preserving unrelated rules. It allows PUT with content-type, cache-control, if-none-match and x-amz-meta-goribu-upload.

Origins come from the app's workers.dev address (unless workersDev: false), Worker custom domains and exact-host routes. Hostname wildcards are skipped with a warning; preview URLs are excluded. Routes without a scheme allow both HTTP and HTTPS origins. There is no separate origins setting. Redeploy after adding or changing app domains or routes.

Direct-upload inspection requires an account workers.dev subdomain unless workersDev: false, plus Workers Scripts Read, Zone Read and Workers Routes Read for the zones it scans. A narrowly scoped token may need additional permissions. A credentials-name check does not prove the keys work or can access the buckets.

Development and deployment

Local development needs no R2 keys and keeps local bucket data across restarts. Goribu creates .goribu/dev-secret once for local signing. Root env files continue loading normally; an app-provided GORIBU_SECRET wins. The generated dev secret is excluded from production output.

Inspect, then deploy
pnpm build
pnpm check
pnpm run deploy

Build validates policies without creating cloud resources. Check is read-only and labels upload metadata with the last build time; without a build it skips only CORS and key checks. Deploy builds fresh, creates missing buckets, connects the public domain, configures direct-upload CORS and generates GORIBU_SECRET if missing. On a first deploy this may create a draft Worker; a later failure leaves it for the retry.

Provisioning requires the relevant R2 read/write permissions and Workers Scripts access; domain attachment also needs Zone Read. Goribu never deletes buckets or objects during provisioning or deployment. Changing bucket names or jurisdiction does not migrate files. Removing the domain setting does not disconnect an existing public domain: public access on a configured private bucket blocks deployment and prints a repair command to run through pnpm exec goribu wrangler.

At runtime, failed upload verification can delete only an object carrying the matching signed upload identity whose size or type is wrong. Your app can explicitly delete files through the request API. Review storage costs and lifecycle rules for files left behind by abandoned forms.