> This page is for Webflow Cloud.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developers.webflow.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developers.webflow.com/_mcp/server.

# Add Object Storage to an app

> Add Object Storage to an app

This guide shows you how to add [Object Storage](/webflow-cloud/storing-data/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](https://www.cursor.com/) or [VS Code](https://code.visualstudio.com/)
* 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.

#### Fork and clone the example repository

To start, [fork the repository on GitHub](https://github.com/Webflow-Examples/webflow-cloud-object-storage/fork) so you have your own copy to work with.

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

```bash
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.

#### 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](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](/_fern-img/99c514af2281f6a7306625c5aff55da1f5312e9e43a06dee8f955a66399d890e.webp)

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](/_fern-img/dcda2938093fde5b74f27df622a74f267fff221bdbaefb83805de4f33406517c.webp)

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](/_fern-img/fd9923fcd8e49b0296c8dc0aefe80729245de23c9e867170c5d8573a68c6a04f.webp)

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

   ![The new application as an independent app in the Workspace](/_fern-img/a92e575af5e567e9a47228570dde66b3834653f0a5f4a33a1a59fe7ac0922f4a.webp)

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](/_fern-img/8a36f32d9b9e4cf55bbab573201c6a23f44ca62abd099f3f6af55403af004c97.webp)

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.

#### 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

```json title={"wrangler.json"}
{
    "r2_buckets": [
       {
          "binding": "CLOUD_FILES",
          "bucket_name": "cloud-files"
       }
    ]
 }
```

#### Generate type definitions for your binding.

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

```bash
npx wrangler types
```

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

#### 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.

#### 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](/_fern-img/38e6be519c147a1dc02afa5b59d2288301a14742a50f1218cb991420e87ad3f6.webp)

#### 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](/_fern-img/37df4e368cb6fe9f0cd40a920a315ecf76da2701c2df8d10cf8bc0acb63a0c2d.webp)

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

#### Uploading files to the local bucket manually

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

```bash
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`.

#### Create an API directory

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

#### 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.

```ts title={"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`.

#### Create a file serving route

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

```ts title={"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.

#### (Optional) View files in the app

If you added assets to your [local bucket](#uploading-files-to-the-local-bucket) in your local app, you can view them now.

To see the app locally, run this command:

```bash
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](/_fern-img/dfd2bcb44f569f4d412e8d26ad019173cd93e5d5949187b67af26a0d22bf0738.webp)

#### 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:

```bash
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.

> **Note**
>
> 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.

#### 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:

```ts title={"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.

#### 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.

#### 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:

```bash
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](/_fern-img/dfd2bcb44f569f4d412e8d26ad019173cd93e5d5949187b67af26a0d22bf0738.webp)

> **Note**
>
> 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](/webflow-cloud/limits) 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:

#### 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:

```ts title={"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.

#### 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:

```ts title={"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.

#### Create the DELETE endpoint

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

```ts title={"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);
  }
};

```

#### Create the OPTIONS endpoint

Similarly, create the OPTIONS endpoint:

```ts title={"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:

```ts title={"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.

#### Start your development server

Start your development server by running the following command:

```bash
npm run dev
```

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

#### 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](/_fern-img/dfd2bcb44f569f4d412e8d26ad019173cd93e5d5949187b67af26a0d22bf0738.webp)

#### 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.

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

#### 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

#### How do I handle CORS requests for the upload route?

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

#### What's the difference between the \`BASE\_URL\` and \`ASSETS\_PREFIX\` environment variables?

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](/webflow-cloud/environments#mount-paths) 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](/webflow-cloud/environment/configuration#mount-path-configuration).

#### Can I create presigned URLs to upload directly to my bucket?

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.

#### Can I expose a public bucket to the web?

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.

#### Why do I get a 413 Content Too Large error when uploading to my bucket?

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