Skip to content

@cfast/storage

@cfast/storage provides a Drizzle-like schema API for file storage on Cloudflare R2. You declare file types — allowed MIME types, max size, destination bucket, key pattern — and the library handles multipart form parsing, validation, and routing to the right bucket. On the client, a drop-in upload hook provides progress tracking and pre-upload validation.

Terminal window
pnpm add @cfast/storage

Peer dependencies: @cfast/env (for R2 bucket bindings), react (for client hook).

Define a storage schema describing your file types:

import { defineStorage, filetype } from "@cfast/storage";
export const storage = defineStorage({
avatars: filetype({
bucket: "UPLOADS",
accept: ["image/jpeg", "image/png", "image/webp"],
maxSize: "2mb",
key: (file, ctx) => `avatars/${ctx.user.id}/${file.name}`,
replace: true,
}),
postImages: filetype({
bucket: "UPLOADS",
accept: ["image/jpeg", "image/png", "image/webp", "image/gif"],
maxSize: "10mb",
key: (file, ctx) => `posts/${ctx.input.postId}/${crypto.randomUUID()}-${file.name}`,
}),
documents: filetype({
bucket: "DOCUMENTS",
accept: ["application/pdf"],
maxSize: "50mb",
multipartThreshold: "10mb",
}),
});

Handle an upload in a React Router action:

export async function action({ request, context }) {
const user = await auth.requireUser(request);
const result = await storage.handle("postImages", request, {
env: context.env,
user,
input: { postId: "123" },
});
// result: { key, size, type, url }
return { success: true, url: result.url };
}

Each filetype() declaration specifies what is allowed. Validation happens in layers, failing fast:

  1. Content-Type header checked before reading the body (415 Unsupported Media Type)
  2. Content-Length header checked before reading the body (413 Payload Too Large)
  3. MIME type verified by reading file magic bytes (prevents spoofed Content-Type)
  4. Actual byte count verified during streaming upload (prevents spoofed Content-Length)

The same schema definitions power both server-side validation and client-side pre-upload checks, with no duplication.

Large files use R2’s multipart upload API automatically. Files below the threshold use a direct PUT. The caller does not need to think about the boundary:

documents: filetype({
bucket: "DOCUMENTS",
maxSize: "200mb",
multipartThreshold: "10mb", // files > 10MB use multipart (default: 5mb)
partSize: "10mb", // size of each part (default: 10mb)
}),

The library splits the incoming stream into parts, uploads them in parallel, retries failed parts, and completes or aborts the multipart upload.

The key function receives the file info and a context object, returning the R2 object key. This gives you full control over file organization:

avatars: filetype({
key: (file, ctx) => `avatars/${ctx.user.id}/${file.name}`,
replace: true, // uploading replaces the previous file at this key
}),
postImages: filetype({
key: (file, ctx) => `posts/${ctx.input.postId}/${crypto.randomUUID()}-${file.name}`,
// unique key per upload, no replacement
}),

The useUpload hook validates files against the schema before sending and tracks progress:

import { useUpload } from "@cfast/storage/client";
function AvatarUploader() {
const upload = useUpload("avatars");
return (
<div>
<input
type="file"
accept={upload.accept} // "image/jpeg,image/png,image/webp" from schema
onChange={(e) => upload.start(e.target.files[0])}
/>
{upload.validationError && <p>{upload.validationError}</p>}
{upload.isUploading && <progress value={upload.progress} max={100} />}
{upload.result && <img src={upload.result.url} alt="Avatar" />}
</div>
);
}

Generate signed URLs for private files or serve directly with custom headers:

// Signed URL (time-limited)
const url = await storage.getSignedUrl("documents", key, { expiresIn: "1h" });
// Public URL
const url = storage.getPublicUrl("postImages", key);
// Stream from R2 with custom response headers
const response = await storage.serve("postImages", key, {
headers: { "Cache-Control": "public, max-age=31536000" },
});

The storage layer handles bytes, not permissions. Gate uploads through @cfast/db operations so that permission checks happen before any file is uploaded:

import { compose } from "@cfast/db";
const uploadPostImage = createAction({
input: { postId: "" as string },
operations: (db, input, ctx) => {
const checkAccess = db.query(posts).findFirst({
where: eq(posts.id, sql.placeholder("postId")),
});
const saveRef = db.insert(postImages).values({
postId: sql.placeholder("postId"),
storageKey: sql.placeholder("storageKey"),
size: sql.placeholder("size"),
});
return compose([checkAccess, saveRef], async (doCheck, doSave) => {
await doCheck({ postId: input.postId });
const result = await storage.handle("postImages", ctx.request, {
env: ctx.env, user: ctx.user, input: { postId: input.postId },
});
await doSave({
postId: input.postId,
storageKey: result.key,
size: result.size,
});
return { url: result.url };
});
},
});

The action’s .permissions includes both read on posts and create on postImages, so the client can check permitted before showing the upload UI.

Run code before and after uploads for tasks like quota checks, image resizing, or database updates:

postImages: filetype({
// ...
hooks: {
beforeUpload: async (file, ctx) => {
// e.g., check quota, validate dimensions
},
afterUpload: async (result, ctx) => {
// e.g., trigger image processing queue
},
},
}),

Validation errors are structured with a code, detail message, and HTTP status:

import { StorageError } from "@cfast/storage";
try {
await storage.handle("avatars", request, { env, user });
} catch (e) {
if (e instanceof StorageError) {
e.code; // "FILE_TOO_LARGE" | "INVALID_MIME_TYPE" | "UPLOAD_FAILED" | "UNAUTHORIZED" | "INVALID_TOKEN"
e.detail; // "File is 5.2MB but avatars allows max 2MB"
e.status; // 413
}
}

Opinionated Routes (storageRoutes + createStorageRouteHandlers)

Section titled “Opinionated Routes (storageRoutes + createStorageRouteHandlers)”

The package ships a ready-made React Router v7 route pair so you do not need to write action handlers by hand. storageRoutes returns a splat-route config entry you spread into routes.ts, and createStorageRouteHandlers builds the matching loader/action pair.

app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { storageRoutes } from "@cfast/storage/plugin";
export default [
...storageRoutes({ handlerFile: "routes/uploads.$.tsx" }),
// ... rest of your routes
] satisfies RouteConfig;
// app/routes/uploads.$.tsx
import { createStorageRouteHandlers } from "@cfast/storage";
import { storage } from "~/storage";
import { requireUser } from "~/auth.server";
const { loader, action } = createStorageRouteHandlers({
storage,
requireUser,
});
export { loader, action };

That single file gives you:

  • POST /uploads/:filetype — multipart upload endpoint that calls storage.handle(filetype, request, ctx) and responds with { key, size, type, url }.
  • GET /uploads/* — streams the R2 object at params["*"], optionally gated by the matching filetype’s ownerCheck or a signed ?token= query parameter.

Each filetype can declare an ownerCheck that runs before the proxy route streams a private object. The check receives the requested key, the authenticated user (from requireUser), and the Workers env, so you can look up the owning row in your database.

export const storage = defineStorage({
productImages: filetype(
{
image: { mimes: ["image/jpeg", "image/png", "image/webp"], maxSize: "10mb" },
document: { mimes: ["application/pdf"], maxSize: "50mb" },
},
{
bucket: "UPLOADS",
key: (file, ctx) => `products/${ctx.input.productId}/${file.name}`,
ownerCheck: async (key, user, env) => {
const db = drizzle((env as { DB: D1Database }).DB);
const productId = key.split("/")[1];
const row = await db
.select({ vendorId: products.vendorId })
.from(products)
.where(eq(products.id, productId))
.get();
return row?.vendorId === user?.vendorId;
},
},
),
});

Unauthorized requests receive a 403 UNAUTHORIZED JSON response instead of the object body.

The per-mime form of filetype() accepts a record of named MIME groups, each with its own maxSize. Every group is enforced independently, so images stay at 10 MB even when the same filetype also accepts 50 MB PDFs:

filetype(
{
image: { mimes: ["image/jpeg", "image/png", "image/webp"], maxSize: "10mb" },
document: { mimes: ["application/pdf"], maxSize: "50mb" },
},
{
bucket: "UPLOADS",
key: (file, ctx) => `assets/${ctx.user.id}/${file.name}`,
},
);

If a client uploads a 12 MB JPEG the pipeline rejects it with FILE_TOO_LARGE even though the filetype also accepts 50 MB PDFs.

storage.signedUrl(key, { env, expiresIn }) mints a token-bearing URL pointing at the /uploads/* proxy route. The token is verified against STORAGE_SECRET before streaming, so you can share private objects without exposing your auth state:

const url = await storage.signedUrl("products/abc/image.jpg", {
env: context.env,
expiresIn: "1h",
});
// → "/uploads/products/abc/image.jpg?token=1700000000.abcdef..."

Expired or tampered tokens are rejected with 403 INVALID_TOKEN.

Upload with structured form fields (handle(name, file, ctx))

Section titled “Upload with structured form fields (handle(name, file, ctx))”

When an action needs to read the file and structured form fields from the same POST, parse request.formData() yourself and hand the resulting File to storage.handle:

export async function action({ request, context }) {
const user = await auth.requireUser(request);
const formData = await request.formData();
const file = formData.get("photo");
const title = formData.get("title");
if (!(file instanceof File)) throw new Response("photo required", { status: 400 });
const result = await storage.handle("postImages", file, {
env: context.env,
user,
input: { postId: "123" },
});
await db.insert(posts).values({ title: String(title), imageKey: result.key }).run({});
return { success: true };
}

Both overloads coexist: pass a Request when the body contains only the file, pass a File when the request also carries structured fields.