Add Object Storage to an app

Upload large files to a bucket in a Webflow Cloud app

This guide shows you how to add Object Storage to an app and use it to store and serve files. You’ll learn how to:

  • Set up an Object Storage binding
  • List and display files from your bucket
  • Upload files to your bucket with proper validation
  • Handle large file uploads using multipart uploads

Prerequisites

Before you begin, make sure you have:

  • A GitHub account
  • A code editor like Cursor or VS Code
  • Node.js 22 or later and npm installed
  • Basic familiarity with JavaScript/TypeScript

Set up the app

This tutorial uses a repository with pre-built UI, backend endpoints, and helper utilities for file uploads and management. Follow along with the steps below and reference the code in the example app to see how Object Storage is used in this Webflow Cloud app. You may need to make some code changes to the app to get it working with your Webflow Cloud environment.

1

Fork and clone the example repository

To start, fork the repository on GitHub so you have your own copy to work with.

Next, clone your fork of the starter repository to your local machine and install dependencies:

git clone https://github.com/<YOUR-GITHUB-USERNAME>/webflow-cloud-object-storage.git
cd webflow-cloud-object-storage
npm install

The app uses Astro with TypeScript and includes all necessary dependencies for Object Storage operations.

2

Create a Webflow Cloud app

Follow these steps to import the application as an independent Webflow Cloud app:

  1. In the Webflow dashboard, click New Project > App or click this link: https://webflow.com/dashboard/cloud/deploy.

  2. On the Deploy to Webflow Cloud page, click Connect GitHub, then click Import a GitHub repository and select the repository that you forked in the previous section. You can provide the URL to the repository and then click Import URL or select your GitHub account to search for a repository and then click Import.

    Selecting the repository to deploy to Webflow Cloud
  3. Give the Webflow Cloud app a name, select the branch of the repository to deploy, and under Deploy to, click New domain.

    Selecting the repository to deploy to Webflow Cloud
  4. Click Deploy and wait for Webflow Cloud to deploy the application.

    Now the application deploys automatically when you push changes to the GitHub repository:

    The complete Webflow Cloud app

    The application also appears as an independent app in your Webflow Workspace:

    The new application as an independent app in the Workspace
  5. Click Manage app or browse to the app in the Workspace to open its settings. By default, the application is deployed to the root of the domain, as shown in the Environment URL column in this screenshot:

    The environments for the application, showing the deployed application
  6. Copy the domain of the application because you’ll need it later.

Add Object Storage binding

The app configuration needs a binding to use Object Storage. This binding tells the app how to connect to the storage resource. After the binding is declared, the app can use simple methods like .list(), .put(), and .delete() to read from and write to the Object Storage bucket.

1

Declare a binding in wrangler.json

Open the app’s wrangler.json file and add or update the r2_buckets array to define the Object Storage buckets with the following information:

  • binding: The variable name to use to access the bucket in your code, which must be a valid JavaScript variable name
  • bucket_name: The name of the bucket to store the files in
wrangler.json
{
"r2_buckets": [
{
"binding": "CLOUD_FILES",
"bucket_name": "cloud-files"
}
]
}
2

Generate type definitions for your binding.

Update the app’s type definitions to enable autocomplete and type safety:

npx wrangler types

Updating the types ensures that your code editor recognizes the Object Storage binding.

3

Deploy your app

Commit and push any local changes to the remote GitHub repo. Webflow Cloud automatically redeploys the app.

CLI deployments do not apply changes to binding configurations

To create a binding, you must commit and push your changes to your linked repository.

4

Access your binding in your Environment Dashboard

  1. Navigate to the app in your Workspace and open its environments.
  2. Select an environment and then click the Storage tab.

This tab appears when the app is deployed and shows the Object Storage binding. If you deployed the sample application, Webflow Cloud automatically filled in the CLOUD_FILES binding from the app’s wrangler.json file.

Upload files to Object Storage bucket
5

Add test files to the bucket

  1. Click the binding to open it.
  2. Add some test files by clicking Upload Object
Upload files to Object Storage bucket

You’ll see the file appear in the bucket in the dashboard.

When you run the app locally, you can upload files to a local bucket by running the following command in your terminal:

npx wrangler r2 object put <YOUR_BUCKET_NAME>/<YOUR_FILE_NAME> --file=<PATH_TO_YOUR_FILE>

This command uploads the file to your local bucket, which you can then access when developing the app.

List files in the bucket

To list the files in the bucket in the application, create a route that accesses the bucket’s contents. This route is provided in the sample application in the file src/pages/api/list-assets.ts.

1

Create an API directory

In your app’s src/pages directory, create an api directory if the app doesn’t have one already.

2

Create a list files route

In your api directory, create a new file named list-assets.ts. This file contains the logic for listing the files in the bucket. This file is provided in the sample application.

list-assets.ts
import type { APIRoute } from "astro";
import { API } from "../../utils/api";
export const GET: APIRoute = async ({ locals, request }) => {
try {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return new Response("Cloud storage not configured", { status: 500 });
}
const options = { limit: 500 };
const listed = await bucket.list(options);
let truncated = listed.truncated;
// Paging through the files
// @ts-ignore
let cursor = truncated ? listed.cursor : undefined;
while (truncated) {
const next = await bucket.list({
...options,
cursor: cursor,
});
listed.objects.push(...next.objects);
truncated = next.truncated;
// @ts-ignore
cursor = next.cursor;
}
// Return the files as a JSON object
return new Response(JSON.stringify(listed.objects), {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error listing assets:", error);
return new Response("Failed to list assets", { status: 500 });
}
};

This code creates a GET route that runs these basic steps:

  1. Accepts the locals object for access to runtime environment variables and bindings.

  2. Accesses the CLOUD_FILES binding.

  3. Uses the Cloudflare list() method to get the files in the bucket with pagination support.

  4. Returns the list of files as a JSON array of R2Object items, each with these properties:

    • key: The file name/path
    • size: File size in bytes
    • etag: Entity tag for caching
    • httpEtag: HTTP-compatible entity tag
    • uploaded: Upload timestamp
    • checksums: File integrity checksums
    • httpMetadata: HTTP headers and metadata

Serve files from the bucket

Now that the application can list files, create a route to serve individual files from the bucket. This route handles file requests and returns the file content with appropriate headers. This route is provided in the sample application in the file src/pages/api/asset.ts.

1

Create a file serving route

In the api directory, create a file named asset.ts with this code:

asset.ts
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ request, locals }) => {
// Get the key from the request
const url = new URL(request.url);
const key = url.searchParams.get("key");
if (!key) {
return new Response("Missing key", { status: 400 });
}
// Get the object from the bucket
const bucket = locals.runtime.env.CLOUD_FILES;
const object = await bucket.get(key as string);
if (!object) {
return new Response("Not found", { status: 404 });
}
// Get the data from the object and return it
const data = await object.arrayBuffer();
const contentType = object.httpMetadata?.contentType ?? "";
return new Response(data, {
headers: {
"Content-Type": contentType,
},
});
};

This code creates a GET route that runs these basic steps:

  1. Creates a GET route that returns a single file based on the file key. Callers pass a key query parameter to specify the file to get and the route returns the file from the bucket via the get() method.
  2. Converts the R2Object object that the get() method returns to a usable format by using the .arrayBuffer() method, which returns the file data as a Uint8Array object.
  3. Extracts the content type from the object’s httpMetadata property, with a fallback to an empty string if not available.
  4. Returns the file with the proper Content-Type header.
2

(Optional) View files in the app

If you added assets to your local bucket in your local app, you can view them now.

To see the app locally, run this command:

npm run dev

This command starts the development server. You can view the app at http://localhost:4321/YOUR_BASE_PATH.

The files that you added to your bucket should appear in the list of files. If you don’t see the files, verify that your code matches the examples above and that the bucket name is correct.

View files in your app
3

Deploy the app

To deploy the app to Webflow Cloud, commit and push the changes to your GitHub repository with commands that look like this example:

git add .
git commit -m "Add asset endpoints"
git push

Go to your environment in Webflow Cloud to see your app deployed. When it is deployed, you can access the app at https://<YOUR_DOMAIN>/<YOUR_BASE_PATH> and see the files that you uploaded via the Webflow Cloud dashboard.

To upload files from the app, you’ll create a new upload route in the next step.

Upload files to the bucket

The next route that you create uploads files from the application to the bucket. This route processes form data, validates files, and stores them in your bucket.

This approach is best for files less than 1MB. For larger files, see the multipart upload route in the next section.

File uploads should be executed on the domain of your worker

To avoid size upload limits, file upload endpoints should be executed on the domain of your worker using the ASSETS_PREFIX environment variable, not your custom Webflow Cloud domain. Because of this, you must handle CORS requests for this route.

1

Create an upload route

In the api directory, open or create the file called upload.ts. This file contains the logic for uploading files to the bucket:

upload.ts
import type { APIRoute } from "astro";
import { API } from "../../utils/api";
export const POST: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const formData = await request.formData();
const file = formData.get("file");
if (!file || !(file instanceof File)) {
return API.error("Missing or invalid file", request, 400);
}
// Generate unique filename with timestamp
const timestamp = Date.now();
const extension = file.name.split(".").pop() || "";
const filename = `${timestamp}-${file.name}.${extension}`;
// Upload to R2 bucket
const object = await bucket.put(filename, file, {
httpMetadata: {
contentType: file.type,
},
});
if (!object) {
return API.error("Failed to upload file", request, 500);
}
return API.success(
{
success: true,
filename,
key: object.key,
size: file.size,
type: file.type,
},
request
);
} catch (error) {
console.error("Upload error:", error);
return API.error("Upload failed", request, 500);
}
};

This code creates a POST route that runs these basic steps:

  1. Initializes the Astro API utility with the origin set in the environment variables and with handling for CORS preflight requests. It also handles an OPTIONS method that browsers use to check if the cross-origin request is allowed before sending the actual file data.
  2. Accesses the object storage bucket.
  3. Extracts the file from the FormData object and verifies that it is a file.
  4. Generates a unique file name for it by combining a timestamp with the file name.
  5. Uploads the file with the put() method and sets the content type.
  6. Returns a success response.
2

Test the upload route

The frontend is already set up to use the upload route. You can test it by navigating to the file uploader page and uploading a file in your development environment. You should see the file appear in the list of files.

3

Deploy the app

Deploy the app to Webflow Cloud to start uploading files from the frontend. Commit and push the changes to the GitHub repository to start a deployment:

git add .
git commit -m "Add upload endpoints"
git push

When your app is deployed, navigate to the file uploader page and upload a file. You should see the file appear in the list of files.

Test your upload route

You may see a 413 Content Too Large error if you attempt to upload large files. For these cases, see the multipart upload route in the next section.

Handle large files with multipart uploads

Webflow Cloud apps have a 100MB request body limit and require requests to complete within 20 seconds. The direct upload approach from the previous section is appropriate for small files. For larger files, use the multipart upload approach shown below.

Multipart uploads break large files into smaller chunks that can be uploaded concurrently, improving upload performance and reliability. This approach allows browsers to upload multiple parts simultaneously and resume interrupted uploads. To allow multipart uploads, you must set up logic on both the server and the browser to handle the multipart upload process.

Create the server endpoints

In a multipart upload, the server-side code handles three main operations:

  1. Initializes the upload session and gets an upload ID from the bucket
  2. Uploads individual file chunks
  3. Combines the parts into the final file and returns the file metadata

This guide covers the creation of the POST and PUT endpoints in the src/pages/api/multipart-upload.ts file to handle these operations:

1

Create the POST endpoint

In the api directory, create or open the file multipart-upload.ts and add the POST endpoint. This endpoint handles two action values: create starts a new upload session and returns an uploadId, and complete assembles all uploaded parts into the final file:

multipart-upload.ts
import type { APIRoute } from "astro";
import { API } from "../../utils/api";
interface MultipartUploadRequest {
key: string;
contentType?: string;
}
interface CompleteMultipartRequest {
uploadId: string;
key: string;
parts: R2UploadedPart[];
}
// Helper function to parse JSON
async function parseRequestData(
request: Request
): Promise<{ [key: string]: any }> {
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return await request.json();
}
throw new Error("Content-Type must be application/json");
}
// Creates and completes a new multipart upload session
export const POST: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (!action) {
return API.error("Missing action parameter", request, 400);
}
switch (action) {
case "create": {
// Create a new multipart upload
const parsedData = await parseRequestData(request);
const body: MultipartUploadRequest = {
key: parsedData.key as string,
contentType: parsedData.contentType as string | undefined,
};
if (!body.key) {
return API.error("Missing key parameter", request, 400);
}
try {
const multipartUpload = await bucket.createMultipartUpload(body.key, {
httpMetadata: body.contentType
? {
contentType: body.contentType,
}
: undefined,
});
return API.success(
{
success: true,
key: multipartUpload.key,
uploadId: multipartUpload.uploadId,
},
request
);
} catch (error) {
console.error("Failed to create multipart upload:", error);
return API.error("Failed to create multipart upload", request, 500);
}
}
case "complete": {
// Complete a multipart upload
const parsedData = await parseRequestData(request);
const body: CompleteMultipartRequest = {
uploadId: parsedData.uploadId as string,
key: parsedData.key as string,
parts: parsedData.parts as R2UploadedPart[],
};
if (!body.uploadId || !body.key || !body.parts) {
return API.error("Missing required parameters", request, 400);
}
try {
const multipartUpload = bucket.resumeMultipartUpload(
body.key,
body.uploadId
);
// Parts are already in R2UploadedPart format
const r2Parts = body.parts;
const object = await multipartUpload.complete(r2Parts);
return API.success(
{
success: true,
key: object.key,
etag: object.httpEtag,
size: object.size,
},
request
);
} catch (error: any) {
console.error("Failed to complete multipart upload:", error);
return API.error(
error.message || "Failed to complete multipart upload",
request,
400
);
}
}
default:
return API.error(`Unknown action: ${action}`, request, 400);
}
} catch (error) {
console.error("Multipart upload error:", error);
return API.error("Multipart upload failed", request, 500);
}
};

This code creates a POST route that runs these basic steps:

  1. Initializes the API utility with the origin from environment variables and handles CORS preflight OPTIONS requests.
  2. Reads the action query parameter to determine whether to create a new upload session or complete an existing one.
  3. For create: calls bucket.createMultipartUpload() with the file key and optional content type, and returns the uploadId and key that the client uses for subsequent part uploads.
  4. For complete: calls bucket.resumeMultipartUpload() with the key and uploadId to get a handle to the existing session, then calls .complete() with the array of uploaded R2UploadedPart items to assemble the final file, and returns the file metadata.
2

Create the PUT endpoint

In the same multipart-upload.ts file, add the PUT endpoint. This endpoint accepts individual file chunks and uploads them to the bucket:

multipart-upload.ts
// Uploads individual parts of a multipart upload
export const PUT: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (action !== "upload-part") {
return API.error(`Unknown action: ${action}`, request, 400);
}
const uploadId = url.searchParams.get("uploadId");
const partNumberStr = url.searchParams.get("partNumber");
const key = url.searchParams.get("key");
if (!uploadId || !partNumberStr || !key) {
return API.error("Missing uploadId, partNumber, or key", request, 400);
}
const partNumber = parseInt(partNumberStr);
if (isNaN(partNumber) || partNumber < 1) {
return API.error("Invalid part number", request, 400);
}
if (!request.body) {
return API.error("Missing request body", request, 400);
}
try {
const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
// Convert request body to ArrayBuffer to get known length
const arrayBuffer = await request.arrayBuffer();
const uploadedPart = await multipartUpload.uploadPart(
partNumber,
arrayBuffer
);
return API.success(
{
success: true,
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
},
request
);
} catch (error: any) {
console.error("Failed to upload part:", error);
return API.error(error.message || "Failed to upload part", request, 400);
}
} catch (error) {
console.error("Upload part error:", error);
return API.error("Upload part failed", request, 500);
}
};

This code creates a PUT route that runs these basic steps:

  1. Initializes the API utility with the origin from environment variables and handles CORS preflight requests.
  2. Reads the uploadId, partNumber, and key query parameters to identify which upload session and part to write.
  3. Calls bucket.resumeMultipartUpload() to get a handle to the existing upload session.
  4. Reads the request body as an ArrayBuffer and calls multipartUpload.uploadPart() with the part number and data.
  5. Returns the partNumber and etag of the uploaded R2UploadedPart item for the client to track.
3

Create the DELETE endpoint

Similarly, create the DELETE endpoint that aborts an upload and removes the file chunks:

multipart-upload.ts
// Aborts a multipart upload
export const DELETE: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (action !== "abort") {
return API.error(`Unknown action: ${action}`, request, 400);
}
const uploadId = url.searchParams.get("uploadId");
const key = url.searchParams.get("key");
if (!uploadId || !key) {
return API.error("Missing uploadId or key", request, 400);
}
try {
const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
await multipartUpload.abort();
return API.success(
{
success: true,
message: "Multipart upload aborted successfully",
},
request
);
} catch (error: any) {
console.error("Failed to abort multipart upload:", error);
return API.error(
error.message || "Failed to abort multipart upload",
request,
400
);
}
} catch (error) {
console.error("Abort multipart upload error:", error);
return API.error("Abort multipart upload failed", request, 500);
}
};
4

Create the OPTIONS endpoint

Similarly, create the OPTIONS endpoint:

multipart-upload.ts
export const OPTIONS: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
return API.cors(request);
};

The complete file looks like this:

multipart-upload.ts
import type { APIRoute } from "astro";
import { API } from "../../utils/api";
interface MultipartUploadRequest {
key: string;
contentType?: string;
}
interface CompleteMultipartRequest {
uploadId: string;
key: string;
parts: R2UploadedPart[];
}
// Helper function to parse JSON
async function parseRequestData(
request: Request
): Promise<{ [key: string]: any }> {
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return await request.json();
}
throw new Error("Content-Type must be application/json");
}
// Creates and completes a new multipart upload session
export const POST: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (!action) {
return API.error("Missing action parameter", request, 400);
}
switch (action) {
case "create": {
// Create a new multipart upload
const parsedData = await parseRequestData(request);
const body: MultipartUploadRequest = {
key: parsedData.key as string,
contentType: parsedData.contentType as string | undefined,
};
if (!body.key) {
return API.error("Missing key parameter", request, 400);
}
try {
const multipartUpload = await bucket.createMultipartUpload(body.key, {
httpMetadata: body.contentType
? {
contentType: body.contentType,
}
: undefined,
});
return API.success(
{
success: true,
key: multipartUpload.key,
uploadId: multipartUpload.uploadId,
},
request
);
} catch (error) {
console.error("Failed to create multipart upload:", error);
return API.error("Failed to create multipart upload", request, 500);
}
}
case "complete": {
// Complete a multipart upload
const parsedData = await parseRequestData(request);
const body: CompleteMultipartRequest = {
uploadId: parsedData.uploadId as string,
key: parsedData.key as string,
parts: parsedData.parts as R2UploadedPart[],
};
if (!body.uploadId || !body.key || !body.parts) {
return API.error("Missing required parameters", request, 400);
}
try {
const multipartUpload = bucket.resumeMultipartUpload(
body.key,
body.uploadId
);
// Parts are already in R2UploadedPart format
const r2Parts = body.parts;
const object = await multipartUpload.complete(r2Parts);
return API.success(
{
success: true,
key: object.key,
etag: object.httpEtag,
size: object.size,
},
request
);
} catch (error: any) {
console.error("Failed to complete multipart upload:", error);
return API.error(
error.message || "Failed to complete multipart upload",
request,
400
);
}
}
default:
return API.error(`Unknown action: ${action}`, request, 400);
}
} catch (error) {
console.error("Multipart upload error:", error);
return API.error("Multipart upload failed", request, 500);
}
};
// Uploads individual parts of a multipart upload
export const PUT: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (action !== "upload-part") {
return API.error(`Unknown action: ${action}`, request, 400);
}
const uploadId = url.searchParams.get("uploadId");
const partNumberStr = url.searchParams.get("partNumber");
const key = url.searchParams.get("key");
if (!uploadId || !partNumberStr || !key) {
return API.error("Missing uploadId, partNumber, or key", request, 400);
}
const partNumber = parseInt(partNumberStr);
if (isNaN(partNumber) || partNumber < 1) {
return API.error("Invalid part number", request, 400);
}
if (!request.body) {
return API.error("Missing request body", request, 400);
}
try {
const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
// Convert request body to ArrayBuffer to get known length
const arrayBuffer = await request.arrayBuffer();
const uploadedPart = await multipartUpload.uploadPart(
partNumber,
arrayBuffer
);
return API.success(
{
success: true,
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
},
request
);
} catch (error: any) {
console.error("Failed to upload part:", error);
return API.error(error.message || "Failed to upload part", request, 400);
}
} catch (error) {
console.error("Upload part error:", error);
return API.error("Upload part failed", request, 500);
}
};
// Aborts a multipart upload
export const DELETE: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
console.log("CORS preflight request from:", request.headers.get("Origin"));
return API.cors(request);
}
try {
// Check if bucket is available
const bucket = locals.runtime.env.CLOUD_FILES;
if (!bucket) {
return API.error("Cloud storage not configured", request, 500);
}
const url = new URL(request.url);
const action = url.searchParams.get("action");
if (action !== "abort") {
return API.error(`Unknown action: ${action}`, request, 400);
}
const uploadId = url.searchParams.get("uploadId");
const key = url.searchParams.get("key");
if (!uploadId || !key) {
return API.error("Missing uploadId or key", request, 400);
}
try {
const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
await multipartUpload.abort();
return API.success(
{
success: true,
message: "Multipart upload aborted successfully",
},
request
);
} catch (error: any) {
console.error("Failed to abort multipart upload:", error);
return API.error(
error.message || "Failed to abort multipart upload",
request,
400
);
}
} catch (error) {
console.error("Abort multipart upload error:", error);
return API.error("Abort multipart upload failed", request, 500);
}
};
export const OPTIONS: APIRoute = async ({ request, locals }) => {
// Set the origin for the API
API.init((locals.runtime as any).env.ORIGIN);
return API.cors(request);
};

Create the browser logic

The browser handles chunking the file and uploading the parts to the server. To do this, the client-side logic must:

  1. Get an upload ID from the server
  2. Send file chunks
  3. Tell the server to combine the chunks into a file

The example repository already implements this logic. Here is a walkthrough of the code:

Test your multipart upload route

Now that your routes are set up, you can test your multipart upload route by uploading a large file.

1

Start your development server

Start your development server by running the following command:

npm run dev

Your server should be running at http://localhost:4321/YOUR_BASE_PATH.

2

Upload a large file

Navigate to the file uploader page and upload a large file. You should see the file appear in the list of files.

Test your upload route
3

Deploy your app

Deploy your app to Webflow Cloud to start uploading large files. Commit and push your changes to your GitHub repository to start a deployment.

git add .
git commit -m "Add multipart upload endpoints"
git push
4

Test your multipart upload route in your Webflow Cloud environment

Navigate to the file uploader page in your Webflow Cloud environment and upload a large file. You should see the file appear in the list of files.

FAQs

Because upload requests are made directly to the worker domain (not your Webflow Cloud domain), you need to handle CORS properly. Add these headers to your response:

Access-Control-Allow-Origin: <YOUR_WEBFLOW_CLOUD_DOMAIN>
Access-Control-Allow-Methods: POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type

If you’re looking to upload files from an authenticated user with session credentials, you’ll need to generate temporary upload URLs with embedded authentication tokens. Since CORS prevents passing session cookies directly, you can create a secure token that contains user information and embed it in the upload URL.

This approach involves:

  • Generating a temporary upload URL with an embedded authentication token
  • Validating the token on the upload endpoint
  • Using the worker domain for the actual file upload

Both the BASE_URL and ASSETS_PREFIX environment variables are automatically set by Webflow Cloud to help with routing logic in your app. You can access these variables as you would any other environment variable in your app.

  • BASE_URL is automatically set to the mount path of your environment (for example, /app). This is useful for setting up redirects and other routing logic in your app.
  • ASSETS_PREFIX is set to the domain of the Worker your app is deployed to (for example, https://YOUR_ENV_HASH.wf-app-prod.cosmic.webflow.services). This link is essential for uploading large files to your bucket, and serving files directly from your app. Because this link will always be a different domain than your app’s domain, you’ll need to handle CORS requests for the upload route.

Learn more about built in environment variables in Webflow Cloud.

No, presigned URLs require credentials that aren’t available in Webflow Cloud’s secure environment. Instead, use the multipart upload endpoints to upload files through your app’s API routes.

No, public buckets aren’t supported in Webflow Cloud. All bucket access must go through your app’s API routes, which gives you control over access permissions and allows you to implement proper authentication and authorization.

Likely the file being uploaded is hitting current Webflow Cloud request limits. You should upload these larger files through the multipart upload strategy.