R2 files & uploads

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

On this page

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.
  },
});
  • Public hostname: domain makes the default bucket public through that hostname. Use a separate media hostname in an active Cloudflare zone in your account.
  • Hostname format: Supply only the hostname, without a scheme, port, or path.
  • Domain activation: Goribu connects the media hostname during deployment. DNS and certificate activation may take time.
  • Single private bucket: For private storage only, omit domain and access the default bucket through req.r2.
  • Second bucket: The optional private bucket requires a public default bucket.
  • Bucket names: Names must be distinct, use 3–63 lowercase letters, digits, or hyphens, and start and end with a letter or digit.
  • Jurisdiction: Omit jurisdiction for default storage or use "eu" for both buckets.

A single private bucket needs only a name:

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

export default defineConfig({
  name: "my-app",
  compatibilityDate: "2026-09-01",
  r2: { name: "my-app-files" },
});
  • Configuration ownership: Configure R2 only through goribu.config.js. Handwritten Wrangler R2 declarations are rejected.
  • Bucket limit: Goribu supports at most two managed buckets.

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.tsx
import type { Handler, R2Upload, UploadPolicy } from "goribu/server";
import { Form, FileUpload } from "goribu";

export const uploads = {
  avatar: { accept: ["image/*"], maxBytes: 5_000_000 },
} satisfies Record<string, UploadPolicy>;

export const POST: Handler = async (req, res) => {
  // The exported policy makes avatar a verified upload, not a raw File.
  const { avatar } = await req.body() as { avatar?: R2Upload };
  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<{ url?: string }> 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>
  );
}
src/routes/profile.jsx
import { Form, FileUpload } from "goribu";

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

export const POST = async (req, res) => {
  // The exported policy makes avatar a verified upload, not a raw File.
  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>
  );
}
  • Immediate transfer: Picking a file starts the transfer immediately.
  • Transfer path: By default, bytes stream through the Worker into R2. direct: true sends them straight to R2 in production.
  • Form submission: Save waits for unfinished transfers, then submits signed references alongside the other form fields. File bytes are not sent again.
  • Verified references: await req.body() verifies each reference against the stored object and returns { key, name, type, size, url? }.
  • Public URLs: Verified files in the public default bucket include url.
  • Missing uploads: Invalid, expired, or failed uploads leave the field absent. Return res.invalid() when the file is required.
  • Application records: Save the verified object key with your application record.
  • Transfer state: uploading reports active transfers. pending reports submission, including its wait for uploads.
  • Form encoding: Managed pickers have no input name, so the form stays URL-encoded unless it also contains an ordinary named file input.
  • JavaScript requirement: Managed upload controls require JavaScript. See Forms for submission and reset behavior.

Policies and verification

Policies on the receiving route
import type { UploadPolicy } from "goribu/server";

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,
  },
} satisfies Record<string, UploadPolicy>;
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,
  },
};
  • Required policy: Each upload field requires accept MIME types and maxBytes, a positive integer.
  • MIME wildcards: Subtype wildcards such as image/* are allowed; */* is not.
  • Size limits: Worker uploads allow up to 100,000,000 bytes. Direct policies allow up to 5,000,000,000 bytes.
  • Private field selection: private: true stores the field in the second bucket, r2.private, and requires that bucket.
  • Single-bucket privacy: private: true does not make a field private on its own. With one bucket and no domain, every upload is already private; leave private out.
  • Custom object keys: The optional synchronous key function receives { req, file, ext } after middleware.
  • Default object keys: Without a key function, Goribu uses uploads/<uuid><ext>.
  • No overwrites: Always choose a fresh key. Uploads refuse to overwrite existing objects; a fixed avatar key makes the next upload fail.
  • Replacing a file: Save the verified new key first, then explicitly delete the old object.
  • Receiving handler: A route exporting upload policies must also export POST, PUT, or PATCH.
  • Server boundary: Upload policies and their key function remain server-only.
  • Worker rendering: An upload-policy route runs on the Worker. Forcing prerender = true fails the build.
  • Static upload pages: A static page can post to a separate action route containing the policies and handler. Its picker has no policy-derived type filter or instant size check; the action still enforces both.
  • Expiry: Transfer targets last 60 seconds. Upload references last one hour.
  • Reference scope: References bind the route pattern, field, bucket, and upload identity. They do not bind a user ID or a particular dynamic parameter.
  • Application authorization: Authorize access in your app, keep references and signed URLs private, and handle repeated submissions deliberately.
  • Content verification: Browser MIME types do not prove a file's contents. Keep files needing moderation private.
  • Public caching: Public uploads receive Cache-Control: public, max-age=31536000, immutable.
  • Media headers: Add an 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>
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>
  • Picker props: FileUpload takes name and an optional render child.
  • File count: Each upload field holds one file. Use distinct names for multiple fields.
  • Render props: The render child receives file, previewUrl, status, errorMessage, showPicker, and dropProps.
  • Status values: Upload status is idle, uploading, done, or error.
  • Opening the picker: Call showPicker() from a user gesture.
  • New selections: Picking a new file cancels the previous upload attempt.
  • Reset and unmount: Reset and unmount clear the reference, abort pending work, and revoke the image preview URL.
  • Stored objects: Reset does not delete a file already stored in R2.
  • Abandoned uploads: Goribu does not automatically clean up abandoned uploads. Configure an R2 lifecycle rule for an appropriate upload prefix if needed.

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: File, signal?: AbortSignal) {
  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;
  }
}
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;
  }
}
  • Displayable errors: UploadError.message is ready to display to the user.
  • Error codes: UploadError.code is type, size, forbidden, network, expired, or server.
  • Cancellation: Cancellation also reports network. Check the signal to suppress cancellation UI.
  • Retry: There is no automatic retry. Call upload() again for a fresh target.

Read, write and download

  • Request APIs: Handlers and middleware receive req.r2 and, when configured, req.r2.private.
  • Native methods: Both buckets expose head, get, put, delete, list, createMultipartUpload, and synchronous resumeMultipartUpload. Options and results follow the Workers R2 API.
  • Content type on writes: 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",
});
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",
});
  • Public URL helper: url(key) requires the public bucket and encodes each path segment. Development uses a local public-file endpoint.
  • Signed URL helper: signedUrl(key, options?) works on either bucket.
  • Signed lifetime: Signed links default to 900 seconds and accept 1–604800 seconds.
  • Download disposition: 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,
});
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 default: immutable defaults to false.
  • Link reuse: With immutable: true, the same key, bucket, and options produce identical URLs within windows of half the requested lifetime. Each newly issued link has at least half its lifetime left.
  • Rotation example: A seven-day immutable lifetime rotates URLs about every 3.5 days.
  • Private browser cache: Immutable responses allow private browser caching bounded by link expiry, not shared CDN caching.
  • Revocation limit: Cached bytes cannot be revoked by logout, permission changes, deletion, or secret rotation. 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.

  • Worker downloads: Signed downloads use the Worker when R2 API keys are absent. Development always uses the Worker.
  • Presigned downloads: With both keys in production, signed downloads use R2's S3 endpoint, including its EU endpoint for EU buckets.
  • Partial credentials: A partial R2 key pair fails explicitly.
  • Worker download features: Worker downloads support HEAD, single byte ranges, and conditional requests.
  • Download sandbox: Worker downloads use Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, so uploaded HTML or SVG cannot run scripts as the app.
  • Browser verification: Inline PDF viewing and attachment downloads were verified in Chrome 154 on macOS.
  • Link authorization: Issue signed links at request time after checking access. Links cannot be baked into prerendered HTML.
  • Bearer access: The Worker download endpoint under /_goribu/ does not run your middleware. A signed link is a bearer token until it expires.
  • Secret rotation: Rotating GORIBU_SECRET invalidates Worker download links and upload references. Presigned downloads use separate R2 credentials.

Direct uploads

A larger private upload
import type { UploadPolicy } from "goribu/server";

export const uploads = {
  video: {
    accept: ["video/mp4"],
    maxBytes: 500_000_000,
    direct: true,
    private: true,
  },
} satisfies Record<string, UploadPolicy>;
A larger private upload
export const uploads = {
  video: {
    accept: ["video/mp4"],
    maxBytes: 500_000_000,
    direct: true,
    private: true,
  },
};
  • Receiving route: Add a direct-upload policy to a route with a receiving handler.
  • Direct transfer: Production sends bytes straight from the browser to R2. Development still uses local Worker storage.
  • Object verification: Both transfer paths verify the object when its reference is submitted and use conditional writes to prevent overwrites.
  • Required secrets: Direct uploads need an R2 API token stored as Worker secrets R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY.
  • Missing-key plan: Since 0.9.2, keys need not be set up before deployment. pnpm check lists missing keys.
  • Interactive setup: When either key is missing, pnpm run deploy from a terminal creates the upload buckets and prints a link to the account's R2 API tokens page, with the buckets to scope a new Object Read & Write token to.
  • Hidden prompts: Deployment asks for each missing key through Wrangler's hidden prompt before uploading the Worker.
  • Name-only inspection: Goribu checks secret names, never values. Keys declared only in .env.production still lead to prompts.
  • Cancelled setup: Cancelling a key prompt stops deploy before Worker upload. Run deploy again to be asked for what is still missing.
  • Non-interactive setup: A deploy without a terminal, such as CI, stops before any change when keys need setup and asks you to deploy once from a terminal.
  • Later deployments: Later deploys reuse stored R2 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.

  • Allowed origins: Direct-upload origins come from the app's workers.dev address unless workersDev: false, Worker custom domains, and exact-host routes.
  • Excluded origins: Hostname wildcards are skipped with a warning. Preview URLs are excluded.
  • Route schemes: Routes without a scheme allow both HTTP and HTTPS origins.
  • Origin updates: There is no separate origins setting. Redeploy after adding or changing app domains or routes.
  • Account inspection: Direct-upload inspection requires an account workers.dev subdomain unless workersDev: false.
  • Inspection permissions: Inspection needs Workers Scripts Read, Zone Read, and Workers Routes Read for the zones it scans. A narrowly scoped token may need additional permissions.
  • Key validation limit: A credentials-name check does not prove the keys work or can access the buckets.

Development and deployment

  • Local storage: Development needs no R2 keys and keeps local bucket data across restarts.
  • Local signing: Goribu creates .goribu/dev-secret once. Root env files still load normally; an app-provided GORIBU_SECRET wins.
  • Production exclusion: The generated dev secret is excluded from production output.
Inspect, then deploy
pnpm build
pnpm check
pnpm run deploy
  • Build validation: Build validates upload policies without creating cloud resources.
  • Read-only check: Check labels upload metadata with the last build time. Without a build, it skips only CORS and key checks.
  • Deployment setup: Deploy builds fresh, creates missing buckets, connects the public domain, configures direct-upload CORS, and generates GORIBU_SECRET if missing.
  • Draft Worker: A first deploy may create a draft Worker. A later failure leaves it for retry.
  • Provisioning permissions: Provisioning needs the relevant R2 read/write permissions and Workers Scripts access. Domain attachment also needs Zone Read.
  • Resource preservation: Goribu never deletes buckets or objects during provisioning or deployment.
  • Renamed storage: Changing bucket names or jurisdiction does not migrate files.
  • Public-domain removal: Removing the domain setting does not disconnect an existing public domain.
  • Private-bucket conflict: Public access on a configured private bucket blocks deployment and prints a repair command to run through pnpm exec goribu wrangler.
  • Verification cleanup: Failed upload verification can delete only an object with the matching signed upload identity whose size or type is wrong.
  • Explicit deletion: Your app can delete files through the request API.
  • Storage costs: Review costs and lifecycle rules for files left behind by abandoned forms.