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
1{
2 "r2_buckets": [
3 {
4 "binding": "CLOUD_FILES",
5 "bucket_name": "cloud-files"
6 }
7 ]
8 }
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
1import type { APIRoute } from "astro";
2import { API } from "../../utils/api";
3
4export const GET: APIRoute = async ({ locals, request }) => {
5 try {
6 // Set the origin for the API
7 API.init((locals.runtime as any).env.ORIGIN);
8
9 // Handle CORS preflight requests
10 if (request.method === "OPTIONS") {
11 console.log("CORS preflight request from:", request.headers.get("Origin"));
12 return API.cors(request);
13 }
14 // Check if bucket is available
15 const bucket = locals.runtime.env.CLOUD_FILES;
16 if (!bucket) {
17 return new Response("Cloud storage not configured", { status: 500 });
18 }
19
20 const options = { limit: 500 };
21 const listed = await bucket.list(options);
22 let truncated = listed.truncated;
23
24 // Paging through the files
25 // @ts-ignore
26 let cursor = truncated ? listed.cursor : undefined;
27
28 while (truncated) {
29 const next = await bucket.list({
30 ...options,
31 cursor: cursor,
32 });
33 listed.objects.push(...next.objects);
34
35 truncated = next.truncated;
36 // @ts-ignore
37 cursor = next.cursor;
38 }
39
40 // Return the files as a JSON object
41 return new Response(JSON.stringify(listed.objects), {
42 headers: { "Content-Type": "application/json" },
43 });
44 } catch (error) {
45 console.error("Error listing assets:", error);
46 return new Response("Failed to list assets", { status: 500 });
47 }
48};

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
1import type { APIRoute } from "astro";
2
3export const GET: APIRoute = async ({ request, locals }) => {
4 // Get the key from the request
5 const url = new URL(request.url);
6 const key = url.searchParams.get("key");
7 if (!key) {
8 return new Response("Missing key", { status: 400 });
9 }
10
11 // Get the object from the bucket
12 const bucket = locals.runtime.env.CLOUD_FILES;
13 const object = await bucket.get(key as string);
14 if (!object) {
15 return new Response("Not found", { status: 404 });
16 }
17
18 // Get the data from the object and return it
19 const data = await object.arrayBuffer();
20 const contentType = object.httpMetadata?.contentType ?? "";
21
22 return new Response(data, {
23 headers: {
24 "Content-Type": contentType,
25 },
26 });
27};

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
1import type { APIRoute } from "astro";
2import { API } from "../../utils/api";
3
4export const POST: APIRoute = async ({ request, locals }) => {
5 // Set the origin for the API
6 API.init((locals.runtime as any).env.ORIGIN);
7
8 // Handle CORS preflight requests
9 if (request.method === "OPTIONS") {
10 console.log("CORS preflight request from:", request.headers.get("Origin"));
11 return API.cors(request);
12 }
13
14 try {
15 // Check if bucket is available
16 const bucket = locals.runtime.env.CLOUD_FILES;
17 if (!bucket) {
18 return API.error("Cloud storage not configured", request, 500);
19 }
20
21 const formData = await request.formData();
22 const file = formData.get("file");
23
24 if (!file || !(file instanceof File)) {
25 return API.error("Missing or invalid file", request, 400);
26 }
27
28 // Generate unique filename with timestamp
29 const timestamp = Date.now();
30 const extension = file.name.split(".").pop() || "";
31 const filename = `${timestamp}-${file.name}.${extension}`;
32
33 // Upload to R2 bucket
34 const object = await bucket.put(filename, file, {
35 httpMetadata: {
36 contentType: file.type,
37 },
38 });
39
40 if (!object) {
41 return API.error("Failed to upload file", request, 500);
42 }
43
44 return API.success(
45 {
46 success: true,
47 filename,
48 key: object.key,
49 size: file.size,
50 type: file.type,
51 },
52 request
53 );
54 } catch (error) {
55 console.error("Upload error:", error);
56 return API.error("Upload failed", request, 500);
57 }
58};

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
1import type { APIRoute } from "astro";
2import { API } from "../../utils/api";
3
4interface MultipartUploadRequest {
5 key: string;
6 contentType?: string;
7}
8
9interface CompleteMultipartRequest {
10 uploadId: string;
11 key: string;
12 parts: R2UploadedPart[];
13}
14
15// Helper function to parse JSON
16async function parseRequestData(
17 request: Request
18): Promise<{ [key: string]: any }> {
19 const contentType = request.headers.get("content-type") || "";
20
21 if (contentType.includes("application/json")) {
22 return await request.json();
23 }
24
25 throw new Error("Content-Type must be application/json");
26}
27
28// Creates and completes a new multipart upload session
29export const POST: APIRoute = async ({ request, locals }) => {
30 // Set the origin for the API
31 API.init((locals.runtime as any).env.ORIGIN);
32
33 // Handle CORS preflight requests
34 if (request.method === "OPTIONS") {
35 console.log("CORS preflight request from:", request.headers.get("Origin"));
36 return API.cors(request);
37 }
38
39 try {
40 // Check if bucket is available
41 const bucket = locals.runtime.env.CLOUD_FILES;
42 if (!bucket) {
43 return API.error("Cloud storage not configured", request, 500);
44 }
45
46 const url = new URL(request.url);
47 const action = url.searchParams.get("action");
48
49 if (!action) {
50 return API.error("Missing action parameter", request, 400);
51 }
52
53 switch (action) {
54 case "create": {
55 // Create a new multipart upload
56 const parsedData = await parseRequestData(request);
57 const body: MultipartUploadRequest = {
58 key: parsedData.key as string,
59 contentType: parsedData.contentType as string | undefined,
60 };
61
62 if (!body.key) {
63 return API.error("Missing key parameter", request, 400);
64 }
65
66 try {
67 const multipartUpload = await bucket.createMultipartUpload(body.key, {
68 httpMetadata: body.contentType
69 ? {
70 contentType: body.contentType,
71 }
72 : undefined,
73 });
74
75 return API.success(
76 {
77 success: true,
78 key: multipartUpload.key,
79 uploadId: multipartUpload.uploadId,
80 },
81 request
82 );
83 } catch (error) {
84 console.error("Failed to create multipart upload:", error);
85 return API.error("Failed to create multipart upload", request, 500);
86 }
87 }
88
89 case "complete": {
90 // Complete a multipart upload
91 const parsedData = await parseRequestData(request);
92 const body: CompleteMultipartRequest = {
93 uploadId: parsedData.uploadId as string,
94 key: parsedData.key as string,
95 parts: parsedData.parts as R2UploadedPart[],
96 };
97
98 if (!body.uploadId || !body.key || !body.parts) {
99 return API.error("Missing required parameters", request, 400);
100 }
101
102 try {
103 const multipartUpload = bucket.resumeMultipartUpload(
104 body.key,
105 body.uploadId
106 );
107
108 // Parts are already in R2UploadedPart format
109 const r2Parts = body.parts;
110
111 const object = await multipartUpload.complete(r2Parts);
112
113 return API.success(
114 {
115 success: true,
116 key: object.key,
117 etag: object.httpEtag,
118 size: object.size,
119 },
120 request
121 );
122 } catch (error: any) {
123 console.error("Failed to complete multipart upload:", error);
124 return API.error(
125 error.message || "Failed to complete multipart upload",
126 request,
127 400
128 );
129 }
130 }
131
132 default:
133 return API.error(`Unknown action: ${action}`, request, 400);
134 }
135 } catch (error) {
136 console.error("Multipart upload error:", error);
137 return API.error("Multipart upload failed", request, 500);
138 }
139};

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
1// Uploads individual parts of a multipart upload
2export const PUT: APIRoute = async ({ request, locals }) => {
3 // Set the origin for the API
4 API.init((locals.runtime as any).env.ORIGIN);
5
6 // Handle CORS preflight requests
7 if (request.method === "OPTIONS") {
8 console.log("CORS preflight request from:", request.headers.get("Origin"));
9 return API.cors(request);
10 }
11
12 try {
13 // Check if bucket is available
14 const bucket = locals.runtime.env.CLOUD_FILES;
15 if (!bucket) {
16 return API.error("Cloud storage not configured", request, 500);
17 }
18
19 const url = new URL(request.url);
20 const action = url.searchParams.get("action");
21
22 if (action !== "upload-part") {
23 return API.error(`Unknown action: ${action}`, request, 400);
24 }
25
26 const uploadId = url.searchParams.get("uploadId");
27 const partNumberStr = url.searchParams.get("partNumber");
28 const key = url.searchParams.get("key");
29
30 if (!uploadId || !partNumberStr || !key) {
31 return API.error("Missing uploadId, partNumber, or key", request, 400);
32 }
33
34 const partNumber = parseInt(partNumberStr);
35 if (isNaN(partNumber) || partNumber < 1) {
36 return API.error("Invalid part number", request, 400);
37 }
38
39 if (!request.body) {
40 return API.error("Missing request body", request, 400);
41 }
42
43 try {
44 const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
45
46 // Convert request body to ArrayBuffer to get known length
47 const arrayBuffer = await request.arrayBuffer();
48 const uploadedPart = await multipartUpload.uploadPart(
49 partNumber,
50 arrayBuffer
51 );
52
53 return API.success(
54 {
55 success: true,
56 partNumber: uploadedPart.partNumber,
57 etag: uploadedPart.etag,
58 },
59 request
60 );
61 } catch (error: any) {
62 console.error("Failed to upload part:", error);
63 return API.error(error.message || "Failed to upload part", request, 400);
64 }
65 } catch (error) {
66 console.error("Upload part error:", error);
67 return API.error("Upload part failed", request, 500);
68 }
69};

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
1// Aborts a multipart upload
2export const DELETE: APIRoute = async ({ request, locals }) => {
3 // Set the origin for the API
4 API.init((locals.runtime as any).env.ORIGIN);
5
6 // Handle CORS preflight requests
7 if (request.method === "OPTIONS") {
8 console.log("CORS preflight request from:", request.headers.get("Origin"));
9 return API.cors(request);
10 }
11
12 try {
13 // Check if bucket is available
14 const bucket = locals.runtime.env.CLOUD_FILES;
15 if (!bucket) {
16 return API.error("Cloud storage not configured", request, 500);
17 }
18
19 const url = new URL(request.url);
20 const action = url.searchParams.get("action");
21
22 if (action !== "abort") {
23 return API.error(`Unknown action: ${action}`, request, 400);
24 }
25
26 const uploadId = url.searchParams.get("uploadId");
27 const key = url.searchParams.get("key");
28
29 if (!uploadId || !key) {
30 return API.error("Missing uploadId or key", request, 400);
31 }
32
33 try {
34 const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
35 await multipartUpload.abort();
36
37 return API.success(
38 {
39 success: true,
40 message: "Multipart upload aborted successfully",
41 },
42 request
43 );
44 } catch (error: any) {
45 console.error("Failed to abort multipart upload:", error);
46 return API.error(
47 error.message || "Failed to abort multipart upload",
48 request,
49 400
50 );
51 }
52 } catch (error) {
53 console.error("Abort multipart upload error:", error);
54 return API.error("Abort multipart upload failed", request, 500);
55 }
56};
4

Create the OPTIONS endpoint

Similarly, create the OPTIONS endpoint:

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

The complete file looks like this:

multipart-upload.ts
1import type { APIRoute } from "astro";
2import { API } from "../../utils/api";
3
4interface MultipartUploadRequest {
5 key: string;
6 contentType?: string;
7}
8
9interface CompleteMultipartRequest {
10 uploadId: string;
11 key: string;
12 parts: R2UploadedPart[];
13}
14
15// Helper function to parse JSON
16async function parseRequestData(
17 request: Request
18): Promise<{ [key: string]: any }> {
19 const contentType = request.headers.get("content-type") || "";
20
21 if (contentType.includes("application/json")) {
22 return await request.json();
23 }
24
25 throw new Error("Content-Type must be application/json");
26}
27
28// Creates and completes a new multipart upload session
29export const POST: APIRoute = async ({ request, locals }) => {
30 // Set the origin for the API
31 API.init((locals.runtime as any).env.ORIGIN);
32
33 // Handle CORS preflight requests
34 if (request.method === "OPTIONS") {
35 console.log("CORS preflight request from:", request.headers.get("Origin"));
36 return API.cors(request);
37 }
38
39 try {
40 // Check if bucket is available
41 const bucket = locals.runtime.env.CLOUD_FILES;
42 if (!bucket) {
43 return API.error("Cloud storage not configured", request, 500);
44 }
45
46 const url = new URL(request.url);
47 const action = url.searchParams.get("action");
48
49 if (!action) {
50 return API.error("Missing action parameter", request, 400);
51 }
52
53 switch (action) {
54 case "create": {
55 // Create a new multipart upload
56 const parsedData = await parseRequestData(request);
57 const body: MultipartUploadRequest = {
58 key: parsedData.key as string,
59 contentType: parsedData.contentType as string | undefined,
60 };
61
62 if (!body.key) {
63 return API.error("Missing key parameter", request, 400);
64 }
65
66 try {
67 const multipartUpload = await bucket.createMultipartUpload(body.key, {
68 httpMetadata: body.contentType
69 ? {
70 contentType: body.contentType,
71 }
72 : undefined,
73 });
74
75 return API.success(
76 {
77 success: true,
78 key: multipartUpload.key,
79 uploadId: multipartUpload.uploadId,
80 },
81 request
82 );
83 } catch (error) {
84 console.error("Failed to create multipart upload:", error);
85 return API.error("Failed to create multipart upload", request, 500);
86 }
87 }
88
89 case "complete": {
90 // Complete a multipart upload
91 const parsedData = await parseRequestData(request);
92 const body: CompleteMultipartRequest = {
93 uploadId: parsedData.uploadId as string,
94 key: parsedData.key as string,
95 parts: parsedData.parts as R2UploadedPart[],
96 };
97
98 if (!body.uploadId || !body.key || !body.parts) {
99 return API.error("Missing required parameters", request, 400);
100 }
101
102 try {
103 const multipartUpload = bucket.resumeMultipartUpload(
104 body.key,
105 body.uploadId
106 );
107
108 // Parts are already in R2UploadedPart format
109 const r2Parts = body.parts;
110
111 const object = await multipartUpload.complete(r2Parts);
112
113 return API.success(
114 {
115 success: true,
116 key: object.key,
117 etag: object.httpEtag,
118 size: object.size,
119 },
120 request
121 );
122 } catch (error: any) {
123 console.error("Failed to complete multipart upload:", error);
124 return API.error(
125 error.message || "Failed to complete multipart upload",
126 request,
127 400
128 );
129 }
130 }
131
132 default:
133 return API.error(`Unknown action: ${action}`, request, 400);
134 }
135 } catch (error) {
136 console.error("Multipart upload error:", error);
137 return API.error("Multipart upload failed", request, 500);
138 }
139};
140
141// Uploads individual parts of a multipart upload
142export const PUT: APIRoute = async ({ request, locals }) => {
143 // Set the origin for the API
144 API.init((locals.runtime as any).env.ORIGIN);
145
146 // Handle CORS preflight requests
147 if (request.method === "OPTIONS") {
148 console.log("CORS preflight request from:", request.headers.get("Origin"));
149 return API.cors(request);
150 }
151
152 try {
153 // Check if bucket is available
154 const bucket = locals.runtime.env.CLOUD_FILES;
155 if (!bucket) {
156 return API.error("Cloud storage not configured", request, 500);
157 }
158
159 const url = new URL(request.url);
160 const action = url.searchParams.get("action");
161
162 if (action !== "upload-part") {
163 return API.error(`Unknown action: ${action}`, request, 400);
164 }
165
166 const uploadId = url.searchParams.get("uploadId");
167 const partNumberStr = url.searchParams.get("partNumber");
168 const key = url.searchParams.get("key");
169
170 if (!uploadId || !partNumberStr || !key) {
171 return API.error("Missing uploadId, partNumber, or key", request, 400);
172 }
173
174 const partNumber = parseInt(partNumberStr);
175 if (isNaN(partNumber) || partNumber < 1) {
176 return API.error("Invalid part number", request, 400);
177 }
178
179 if (!request.body) {
180 return API.error("Missing request body", request, 400);
181 }
182
183 try {
184 const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
185
186 // Convert request body to ArrayBuffer to get known length
187 const arrayBuffer = await request.arrayBuffer();
188 const uploadedPart = await multipartUpload.uploadPart(
189 partNumber,
190 arrayBuffer
191 );
192
193 return API.success(
194 {
195 success: true,
196 partNumber: uploadedPart.partNumber,
197 etag: uploadedPart.etag,
198 },
199 request
200 );
201 } catch (error: any) {
202 console.error("Failed to upload part:", error);
203 return API.error(error.message || "Failed to upload part", request, 400);
204 }
205 } catch (error) {
206 console.error("Upload part error:", error);
207 return API.error("Upload part failed", request, 500);
208 }
209};
210
211// Aborts a multipart upload
212export const DELETE: APIRoute = async ({ request, locals }) => {
213 // Set the origin for the API
214 API.init((locals.runtime as any).env.ORIGIN);
215
216 // Handle CORS preflight requests
217 if (request.method === "OPTIONS") {
218 console.log("CORS preflight request from:", request.headers.get("Origin"));
219 return API.cors(request);
220 }
221
222 try {
223 // Check if bucket is available
224 const bucket = locals.runtime.env.CLOUD_FILES;
225 if (!bucket) {
226 return API.error("Cloud storage not configured", request, 500);
227 }
228
229 const url = new URL(request.url);
230 const action = url.searchParams.get("action");
231
232 if (action !== "abort") {
233 return API.error(`Unknown action: ${action}`, request, 400);
234 }
235
236 const uploadId = url.searchParams.get("uploadId");
237 const key = url.searchParams.get("key");
238
239 if (!uploadId || !key) {
240 return API.error("Missing uploadId or key", request, 400);
241 }
242
243 try {
244 const multipartUpload = bucket.resumeMultipartUpload(key, uploadId);
245 await multipartUpload.abort();
246
247 return API.success(
248 {
249 success: true,
250 message: "Multipart upload aborted successfully",
251 },
252 request
253 );
254 } catch (error: any) {
255 console.error("Failed to abort multipart upload:", error);
256 return API.error(
257 error.message || "Failed to abort multipart upload",
258 request,
259 400
260 );
261 }
262 } catch (error) {
263 console.error("Abort multipart upload error:", error);
264 return API.error("Abort multipart upload failed", request, 500);
265 }
266};
267
268export const OPTIONS: APIRoute = async ({ request, locals }) => {
269 // Set the origin for the API
270 API.init((locals.runtime as any).env.ORIGIN);
271 return API.cors(request);
272};

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.