How to collect custom fields and file uploads alongside Webflow checkout with Webflow Cloud

How to collect custom fields and file uploads alongside Webflow checkout with Webflow Cloud

Learn how to collect engraving text and artwork files with a Next.js app on Webflow Cloud.

How to collect custom fields and file uploads alongside Webflow checkout with Webflow Cloud

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Webflow checkout collects short text, but artwork files need a small Webflow Cloud app that hands the shopper a reference code.

Custom products need details before you can make them, like engraving text and a print-ready file. Webflow checkout's Additional info block collects short text, but it has no file upload field, so artwork ends up in email threads, and orders can stall while you chase it.

In this guide, we’ll build a small Next.js app on Webflow Cloud, mounted on the store's own domain. It validates the fields and the file, forwards the upload to storage you control, and gives the shopper a reference code.

The shopper pastes that code into the checkout's Additional info field, so it arrives with the order, and you can match every file without guessing.

What do you need to add custom checkout fields and file uploads in Webflow?

Before setting up file uploads and custom fields, make sure you have the right tools and services configured for a smooth integration.

Here is what you need to get started:

  • A live Webflow store: A site selling through an Ecommerce plan (Standard, Plus or Advanced), with at least one product that needs custom details from the shopper.
  • A Webflow Cloud environment: One environment that serves the app at its mount path (/order-details in this build) and holds the app's environment variables.
  • A Next.js project: Webflow Cloud's framework requirements set Next.js 15 or higher, and the same page states that "Currently, Webflow Cloud supports only the npm package manager."
  • A storage endpoint for files: An HTTPS endpoint you control that accepts a multipart POST with a bearer token, such as a small API in front of an object storage bucket.
  • A short field list: The exact inputs each product needs (engraving text, notes, one artwork file), written down before you build the form so validation matches them.

Webflow Cloud is available starting from the free Starter plan. However, attaching an app to a custom domain requires a Premium plan or higher, and conducting live transactions requires an Ecommerce plan.

Webflow Cloud has built-in object storage, but it’s capped at 100 MB on Ecommerce Standard, so high-volume stores should use their own storage bucket.

6 steps to add custom checkout fields and file uploads in Webflow Cloud

The build is one form page and one Route Handler in a Next.js app on Webflow Cloud. The handler checks each file, fingerprints it, and forwards it to external storage, since Workers have no local disk.

1. Scaffold a Next.js app for Webflow Cloud

Create a fresh Next.js project with TypeScript and the App Router, pinned to Next.js 15, and use npm throughout. Version 15 keeps middleware.ts working on Webflow Cloud, which matters when you add rate limiting in step 4. Webflow Cloud supports only npm, so the scaffold command uses --use-npm.

Run this command to scaffold the project:

npx create-next-app@15 order-details --typescript --app --use-npm
cd order-details
npm run dev

Leave next.config alone. Webflow's configuration docs say, "You don't need to add an adapter, a base path, or an output mode." The base path is injected at build time, so the client reads the mount path from NEXT_PUBLIC_BASE_PATH instead, a variable you add yourself because Webflow Cloud does not create it.

2. Store the upload credentials as environment variables

Define three variables: the mount path the client needs for its fetch calls, the storage endpoint, and the storage token. Webflow Cloud does not rewrite client-side fetch calls to include the mount path, so the form needs the path as a public variable. The token stays server-only.

Add these to .env.local for local development:

NEXT_PUBLIC_BASE_PATH=/order-details
UPLOAD_ENDPOINT=https://storage.example.com/uploads
UPLOAD_TOKEN=replace-with-your-storage-token

Then add the same three variables to your Webflow Cloud environment, marking UPLOAD_TOKEN as a secret. Webflow's environment variables docs state that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime," and secret values are redacted from build logs.

Once saved, the local app and the deployed environment read the same variable names, so one codebase runs in both.

3. Build the order-details form

Replace app/page.tsx with a client component that posts the fields and the file as multipart/form-data. The fetch URL starts with NEXT_PUBLIC_BASE_PATH because the app is mounted at /order-details.

Without the prefix, a request to /api/order-details goes to your Webflow site's root instead of the app. The accept attribute on the file input is a convenience for the shopper; the Route Handler enforces the real rules.

Here is the complete form component:

// app/page.tsx
'use client';

import { useState, type FormEvent } from 'react';

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';

export default function OrderDetailsPage() {
  const [status, setStatus] = useState<'idle' | 'sending' | 'done' | 'error'>('idle');
  const [reference, setReference] = useState('');
  const [message, setMessage] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus('sending');
    setMessage('');

    const formData = new FormData(event.currentTarget);
    const product = new URLSearchParams(window.location.search).get('product');
    if (product) formData.set('product', product);

    try {
      const response = await fetch(`${basePath}/api/order-details`, {
        method: 'POST',
        body: formData,
      });
      const result = await response.json();
      if (!response.ok) {
        setStatus('error');
        setMessage(result.error ?? 'Upload failed. Please try again.');
        return;
      }
      setReference(result.reference);
      setStatus('done');
    } catch {
      setStatus('error');
      setMessage('Network error. Please try again.');
    }
  }

  if (status === 'done') {
    return (
      <main style={{ maxWidth: 520, margin: '48px auto', fontFamily: 'sans-serif' }}>
        <h1>Details received</h1>
        <p>
          Your reference code is <strong>{reference}</strong>. Paste it into the Reference code
          field at checkout so we can match your file to your order.
        </p>
        <button type="button" onClick={() => window.history.back()}>
          Back to the product
        </button>
      </main>
    );
  }

  return (
    <main style={{ maxWidth: 520, margin: '48px auto', fontFamily: 'sans-serif' }}>
      <h1>Personalize your order</h1>
      <form onSubmit={handleSubmit}>
        <label>
          Email (use the same one at checkout)
          <input name="email" type="email" required />
        </label>
        <label>
          Engraving text
          <input name="engravingText" type="text" maxLength={40} required />
        </label>
        <label>
          Notes for our team
          <textarea name="notes" maxLength={500} rows={4} />
        </label>
        <label>
          Artwork file (PNG, JPEG or PDF)
          <input name="artwork" type="file" accept="image/png,image/jpeg,application/pdf" required />
        </label>
        <button type="submit" disabled={status === 'sending'}>
          {status === 'sending' ? 'Uploading…' : 'Save my details'}
        </button>
        {status === 'error' && <p role="alert">{message}</p>}
      </form>
    </main>
  );
}

This gives you a form with four inputs, a disabled state while uploading, and a confirmation screen that shows the reference code. If you submit it now, you will get an error because its Route Handler is not in place yet.

4. Write the Route Handler that validates and forwards the file

Create app/api/order-details/route.ts. It rejects bad input, fingerprints the file and passes everything to your storage endpoint in one request. Do not add export const runtime = 'edge' to this file, even though one Webflow docs page tells you to.

Webflow Cloud deploys through the OpenNext Cloudflare adapter, which does not support that target, so the build breaks. The handler also never writes to disk; it forwards the file as soon as it arrives.

The route is public so that anyone can post files to it. Before you link it from a live store, add a rate limit and make the storage endpoint reject requests without the bearer token. The storage token never leaves the server; only the mount path uses the NEXT_PUBLIC_ prefix.

Creating the validation and forwarding handler

Create the validation and forwarding handler in app/api/order-details/route.ts:

// app/api/order-details/route.ts
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'application/pdf'];

function toHex(buffer: ArrayBuffer): string {
  return [...new Uint8Array(buffer)]
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
}

async function md5Checksum(bytes: ArrayBuffer): Promise<string | null> {
  try {
    return toHex(await crypto.subtle.digest('MD5', bytes));
  } catch {
    // MD5 is a Cloudflare Workers extension; other runtimes may not support it.
    return null;
  }
}

export async function POST(request: Request) {
  const uploadEndpoint = process.env.UPLOAD_ENDPOINT;
  const uploadToken = process.env.UPLOAD_TOKEN;
  if (!uploadEndpoint || !uploadToken) {
    return Response.json({ error: 'Upload storage is not configured.' }, { status: 500 });
  }

  // Reject oversized bodies before parsing, so a huge upload never fills memory.
  const length = Number(request.headers.get('content-length') ?? 0);
  if (length > MAX_BYTES + 64 * 1024) {
    return Response.json({ error: 'File is larger than 10 MB.' }, { status: 413 });
  }

  let form: FormData;
  try {
    form = await request.formData();
  } catch {
    return Response.json({ error: 'Expected a multipart form submission.' }, { status: 400 });
  }

  const email = String(form.get('email') ?? '').trim();
  const engravingText = String(form.get('engravingText') ?? '').trim();
  const notes = String(form.get('notes') ?? '').trim();
  const product = String(form.get('product') ?? '').trim();
  const artwork = form.get('artwork');

  if (!email.includes('@') || !engravingText) {
    return Response.json({ error: 'Email and engraving text are required.' }, { status: 400 });
  }
  if (engravingText.length > 40 || notes.length > 500) {
    return Response.json({ error: 'Text is longer than allowed.' }, { status: 400 });
  }
  if (!(artwork instanceof File) || artwork.size === 0) {
    return Response.json({ error: 'Attach an artwork file.' }, { status: 400 });
  }
  if (!ALLOWED_TYPES.includes(artwork.type)) {
    return Response.json({ error: 'Upload a PNG, JPEG or PDF file.' }, { status: 415 });
  }
  if (artwork.size > MAX_BYTES) {
    return Response.json({ error: 'File is larger than 10 MB.' }, { status: 413 });
  }

  const checksum = await md5Checksum(await artwork.arrayBuffer());
  const reference = crypto.randomUUID().slice(0, 8).toUpperCase();

  const outbound = new FormData();
  outbound.append('file', artwork);
  outbound.append('reference', reference);
  outbound.append('email', email);
  outbound.append('engravingText', engravingText);
  outbound.append('notes', notes);
  outbound.append('product', product);
  if (checksum) outbound.append('md5', checksum);

  let upload: Response;
  try {
    upload = await fetch(uploadEndpoint, {
      method: 'POST',
      headers: { Authorization: `Bearer ${uploadToken}` },
      body: outbound,
      signal: AbortSignal.timeout(15000),
    });
  } catch {
    return Response.json({ error: 'Storage is unreachable.' }, { status: 502 });
  }

  if (!upload.ok) {
    return Response.json({ error: 'Storage rejected the upload.' }, { status: 502 });
  }

  return Response.json({ reference });
}

Add rate limiting in middleware.ts, not proxy.ts, and keep the counter in a shared store such as Webflow Cloud's key value storage, because memory is not shared between requests. Have the storage side check the file's actual bytes before processing it, because the artwork.type value comes from the browser.

The handler checks the request's size before parsing it, since a body up to the platform's 100 MB limit would otherwise be read into memory before the 10 MB check runs. The record ties an email to a file, so set a retention period on your storage and delete uploads once the order ships.

The MD5 checksum gives you a cheap way to spot duplicate files and transfer corruption on the storage side. That is all it is for. Cloudflare provides MD5 through crypto.subtle.digest('MD5', ...) as a non-standard Workers extension.

Because it is outside the Web Crypto standard, the handler catches the failure and sends no checksum when it runs on a runtime without the extension. With the route in place, submitting the form locally should return a reference code and create a record at your storage endpoint.

5. Link the product page to the app

Add a button or link block to the Product template page in the Designer that points to /order-details. Shoppers reach the form from the product they are about to buy, on the same domain as your store, so the hand-off back to the cart doesn't look like leaving the site.

If you sell several customizable products, check whether your product-page setup supports product-specific links. If it does, include a product identifier, such as the slug, in the ?product= value. The form records that value with the upload, so you can see which item a file belongs to.

Label the button with what it does, such as "Add engraving and artwork", and place it above the add-to-cart button, so the details step comes before payment. Then add a text input labeled Reference code to the checkout's Additional info block on both the Checkout and Checkout (PayPal) pages.

The code arrives with the order as customData, which the Orders API and the new order webhook both return, so you can match every file exactly. Preview the product page and click the button. The form should open, and after a successful upload, the back button should return you to the product.

6. Deploy to Webflow Cloud and run a test order

Deploy the app to the Webflow Cloud environment mounted at /order-details, then test the full path as a shopper would. Mounting on the store's custom domain needs Premium or higher. Check the deployed build log for errors. Secrets are redacted there.

Run these three checks on the live environment:

  • Valid submission: Upload a small PNG with engraving text. The confirmation screen shows a reference, and your storage holds the file with matching fields.
  • Oversized file: Upload a file above your cap. The form shows the size error, and nothing new appears at the storage endpoint.
  • Order match: Place a test order with the reference code in the Additional info field. The order's customData should show the same code your storage holds.

When all three pass, the deployed app is collecting validated details and files on your store's domain, and every order carries the code that links it to its upload.

What causes Webflow checkout uploads to fail?

Most failures come from a runtime directive copied from the docs or a missing mount path on client fetches. The rest come from file system code that only works on a laptop and from environment variables that never reached the deployed environment.

The deploy build fails on an API route that worked locally

Cause: The export const runtime = 'edge' directive sets the Next.js edge runtime target, which the OpenNext Cloudflare adapter used by Webflow Cloud does not support, so the directive produces broken builds.

The Webflow Cloud bring-your-own-app page uses "edge" in two different senses. The platform sense is accurate: "Webflow Cloud deploys your app using the Edge runtime, enabling fast, globally distributed hosting," meaning your app runs on Cloudflare Workers. The same page then tells you to add the directive to API routes, but the Next.js edge runtime target is a separate thing.

Fix: Delete export const runtime = 'edge' from every route file and redeploy. Your handler already runs on Cloudflare's edge platform without it.

The form returns 404 on the live site but posts fine on localhost

Cause: The deployed app lives under its mount path, and Webflow Cloud does not rewrite client-side fetch URLs to include it. A bare /api/order-details request goes to your Webflow site, which has no such page, and returns a 404. Locally, the app runs at the root of localhost:3000, so /api/order-details resolves correctly. The bring-your-own-app docs say so directly: "Client-side fetch calls must manually include the base path."

Fix: Confirm NEXT_PUBLIC_BASE_PATH is set in the Webflow Cloud environment to the exact mount path, /order-details in this build, with a leading slash and no trailing slash. Redeploy after changing the value so the new build picks it up. Open the browser's Network tab and confirm the request URL includes the mount path.

Uploads fail on the deployed app with a filesystem error on the route

Cause: On Webflow Cloud, the handler runs on Cloudflare Workers, where node:fs either fails to load or writes to a memory-based file system that is gone when the request ends. Code that saves files under next dev has no lasting disk to write to once deployed.

Fix: Remove any writeFile, temporary folder or fs import from the handler. Keep the file in memory as an ArrayBuffer for the length of the request and forward it to external storage, as the upload handler does. If you want a local copy during development, write it from your storage service, not from the Webflow Cloud app.

Storage rejects uploads with 401 only on the deployed app

Cause: The handler found an UPLOAD_ENDPOINT but sent an empty or stale bearer token. Usually UPLOAD_TOKEN exists in .env.local and was never added to the Webflow Cloud environment, or it was added to a different environment from the one serving /order-details. Because build logs redact secrets, they won't show the value, making a typo hard to spot.

Fix: Open the variables for the specific Webflow Cloud environment serving the mount path and re-enter UPLOAD_TOKEN as a secret, copied directly from your storage provider. Redeploy, then submit a small file. If the 401 continues, call the storage endpoint from your terminal with the same token. That tells you whether the token or the app is the problem.

What you can build next with Webflow Cloud and Webflow checkout

Once a store collects reliable custom details, the next task is usually showing that data back to shoppers and your team without spreadsheets. I'd build a shopper lookup page or an authenticated upload list next. The upload handler already produces a reference, an email and a stored file for every personalized order, so the follow-up features reuse that data.

A lookup page in the same Webflow Cloud app could let a shopper enter a reference code and confirm their file arrived, and an internal route behind authentication could list the day's uploads for your production team.

To act on each order as it arrives, receive the new order event through verified Webflow webhooks and read its customData.

For the fields an order carries, including customData, Webflow's developer docs document the new order event payload.

Frequently asked questions

Does my store need a paid plan for this?

Yes. A real checkout needs an Ecommerce plan, and mounting the app on your store's custom domain requires Premium or higher. You can build the app on the free Starter plan, which includes Webflow Cloud hosting.

Can Webflow checkout collect these fields without an app?

Partly. The checkout's Additional info block collects text inputs, text areas and checkboxes, which arrive with the order as customData. It has no file upload field, and web payments such as Apple Pay skip the checkout page and its Additional info fields, so artwork still needs the app.

Can I rate-limit the upload route with proxy.ts?

Use middleware.ts instead. Next.js 16 renames middleware to proxy, but proxy runs on the Node runtime and cannot opt into Edge. Webflow's framework docs state: "Only Edge runtime middleware works on the Workers runtime." Renaming the file produces middleware that cannot run on Webflow Cloud, so the rate limit does not apply. Keep middleware.ts when you upgrade to Next.js 16.

Can I build the form app with Astro or Vite instead of Next.js?

Yes. Webflow Cloud also supports Astro and Vite with React, Vue, Svelte or vanilla JS, each with its own version floor on the bring-your-own-app page. The upload logic carries over directly: parse the multipart body, validate, forward to storage. The Next.js-specific details, such as NEXT_PUBLIC_BASE_PATH and Route Handler file paths, will change.


Last Updated
October 30, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo

Jetzt kostenlos loslegen

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Get started — it’s free
Demo ansehen
Demo ansehen

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.