A Webflow Cloud route can add photos to a CMS gallery by merging them into the stored list and writing the whole list back.
When updating multi-image CMS fields through the Webflow API, passing only new assets overwrites the existing gallery. A multi-image field stores an ordered list of up to 25 images, and the Webflow API replaces that whole list on every write. Code that sends only new photos deletes everything editors placed manually.
I built the fix as a small Next.js route on Webflow Cloud that reads the gallery, appends only the images it lacks, and writes the full list back. A shared secret keeps the route private to your own systems, and the same call works for a DAM or a nightly supplier job.
What do you need to update multi-image CMS fields with the Webflow API?
You need five things:
- A site with the CMS: Starter includes 50 CMS items for testing and Basic has no CMS, while Premium raises the cap to 20,000 items and 40 Collections, per Webflow's pricing page.
- Collection with a multi-image field: Note the Collection ID, one item ID for testing, and the field's slug, which may differ from the label editors see in the CMS.
- API token: A site token with the
CMS:readandCMS:writescopes, kept out of the repository and stored as a Webflow Cloud secret. - Next.js 15 on npm: Webflow Cloud supports Next.js 15 and only the npm package manager, so scaffold with a pinned version and install dependencies with npm.
- Node.js 22 or later: Install Node.js 22 or later on your machine, as Webflow Cloud's docs state as the general floor.
Everything after this depends on one rule in lib/webflow.ts: the route always writes the whole gallery back and never sends a partial list.
6 steps to update multi-image CMS fields in Webflow Cloud
The build is a Next.js app with an API helper module and one guarded POST route. The merge is the core of the route: it reads the gallery before every write, so existing images survive each update.
1. Create the Next.js app for Webflow Cloud
Scaffold a new Next.js project, or open the one you already deploy to Webflow Cloud. Install dependencies with npm, the only package manager Webflow Cloud supports, and delete any pnpm-lock.yaml or yarn.lock left in the repository.
I keep a gallery route in its own small app mounted on a path such as /gallery-sync. That keeps the token and the route away from the marketing pages editors touch every day.
Scaffold the project with a pinned version 15 so the app matches what Webflow Cloud supports:
npx create-next-app@15 gallery-sync --typescript --app --no-src-dir --import-alias "@/*" --use-npm
cd gallery-sync
npm run dev
The --import-alias flag lets the route file resolve @/lib/webflow. Do not add export const runtime = 'edge' to any file.
Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support that runtime target, so leave the runtime export out of every Route Handler. With the dev server running, the starter page loads at localhost:3000.
2. Add the token and IDs as environment variables
Add four variables: the API token and Collection ID, plus the gallery field's slug and a shared secret your callers send. For local work, use .env.local, which the create-next-app .gitignore already excludes.
Webflow's environment variables page says both secret and plain variables are available to the build and to the deployed app at runtime, and secret values are redacted from build logs.
The local file looks like this, with your own values in place of the placeholders:
WEBFLOW_API_TOKEN=your-site-api-token
WEBFLOW_COLLECTION_ID=your-collection-id
WEBFLOW_GALLERY_FIELD=gallery
GALLERY_SYNC_SECRET=replace-with-a-long-random-string
Keep the token server-side by leaving NEXT_PUBLIC_ off its name. Next.js inlines variables with that prefix into the JavaScript it sends to the browser, so a CMS write token there lets anyone edit your Collection.
The four names now live in .env.local, and process.env.WEBFLOW_API_TOKEN resolves for lib/webflow.ts.
3. Inspect the gallery field on a real item
Request one real item from the Webflow API and read how the gallery field comes back before you write any merge logic. Use that response to inspect the stored entries, then check the API documentation for the shape a write accepts.
Every call in this build uses the v2 Data API: GET and PATCH on /v2/collections/{collection_id}/items/{item_id} with a bearer token.
Webflow documents the format: a read returns fileId, url and alt for each image, and a write takes url and an optional alt. Each image must be on a publicly accessible URL and no larger than 4 MB, and a multi-image field holds at most 25 images.
Request the item with curl:
curl -s "https://api.webflow.com/v2/collections/YOUR_COLLECTION_ID/items/ITEM_ID" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "accept: application/json"
Find your gallery slug in the response, under fieldData, with one entry per image. If the array is empty, add two images to that item in the CMS by hand and run the request again, since an empty list tells you nothing about the shape. If the request fails, confirm that your token can access the Collection before going further.
4. Write the Webflow API helpers
Create a module with the three operations the route needs. I separated them into getGallery (read the gallery), mergeImages (add new entries), and setGallery (write it back).
setGallery sends a PATCH whose body holds only the gallery field; the code never sends the item's name, slug, or other fields. Because a write to a list field can replace the stored list, the helper always sends the full merged list, never just the new entries.
The merge treats an image as a duplicate if either its URL or its alt text already exists on the item, and it keeps existing entries exactly as the read returned them, so identifiers and alt text that editors set survive the round trip.
The alt text is a value your system sends and controls, so if the URL in a later read differs from the one you sent, the alt text match still catches the repeat. The flip side: two different photos sent with the same alt text, such as "Product photo", count as one, and the second is skipped. Send alt text that names the product and the view, for example, "Oak chair, side view".
The module calls the Update Item endpoint with PATCH. It sends existing entries back exactly as they were read, with their fileId and url, which matches the request example in Webflow's API reference, and sends new entries as url plus alt.
The write sets skipInvalidFiles=false, because by default Webflow silently drops an image it cannot fetch and still reports success.
Save it as lib/webflow.ts:
// lib/webflow.ts
const API_BASE = 'https://api.webflow.com/v2';
export type GalleryImage = Record<string, unknown> & { url?: string; alt?: string };
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
}
function itemUrl(itemId: string): string {
const collectionId = requireEnv('WEBFLOW_COLLECTION_ID');
return `${API_BASE}/collections/${collectionId}/items/${encodeURIComponent(itemId)}`;
}
function headers(): HeadersInit {
return {
Authorization: `Bearer ${requireEnv('WEBFLOW_API_TOKEN')}`,
'Content-Type': 'application/json',
Accept: 'application/json',
};
}
export type Gallery = { images: GalleryImage[]; lastUpdated: string | null };
export async function getGallery(itemId: string): Promise<Gallery> {
const res = await fetch(itemUrl(itemId), { headers: headers() });
if (!res.ok) {
throw new Error(`Webflow read failed: ${res.status} ${await res.text()}`);
}
const item = (await res.json()) as {
lastUpdated?: string;
fieldData?: Record<string, unknown>;
};
const field = item.fieldData?.[requireEnv('WEBFLOW_GALLERY_FIELD')];
return {
images: Array.isArray(field) ? (field as GalleryImage[]) : [],
lastUpdated: item.lastUpdated ?? null,
};
}
export function mergeImages(current: GalleryImage[], incoming: GalleryImage[]): GalleryImage[] {
const urls = new Set(current.map((img) => img.url).filter(Boolean));
const alts = new Set(current.map((img) => img.alt).filter(Boolean));
const additions: GalleryImage[] = [];
for (const img of incoming) {
if ((img.url && urls.has(img.url)) || (img.alt && alts.has(img.alt))) continue;
if (img.url) urls.add(img.url);
if (img.alt) alts.add(img.alt);
additions.push(img);
}
return [...current, ...additions];
}
export async function setGallery(itemId: string, images: GalleryImage[]): Promise<number> {
// skipInvalidFiles=false makes one bad URL fail the whole write instead of vanishing.
const res = await fetch(`${itemUrl(itemId)}?skipInvalidFiles=false`, {
method: 'PATCH',
headers: headers(),
body: JSON.stringify({
fieldData: { [requireEnv('WEBFLOW_GALLERY_FIELD')]: images },
}),
});
if (!res.ok) {
throw new Error(`Webflow update failed: ${res.status} ${await res.text()}`);
}
const item = (await res.json()) as { fieldData?: Record<string, unknown> };
const saved = item.fieldData?.[requireEnv('WEBFLOW_GALLERY_FIELD')];
return Array.isArray(saved) ? saved.length : 0;
}
These helpers throw with Webflow's status code and response text on any failure, so a failed call reports exactly what went wrong, for example, when a token expires. setGallery returns the number of images Webflow actually stored, read from its response rather than from the list it sent.
5. Build the guarded Route Handler
Add a POST Route Handler that checks the shared secret, rejects bad input, and then calls the helpers in order. I use a small constant-time comparison written in plain JavaScript instead of timingSafeEqual to keep the secret check compatible with both environments.
That function exists on the deployed Workers runtime but not under local next dev, so code that depends on it can pass in production and fail on your laptop, or the reverse.
The route has no rate limit of its own, so keep the secret long and random and call it only from your own systems. Each request spends three Data API calls against a rate limit of 60 or 120 a minute, depending on plan.
Route implementation
Create app/api/gallery/[itemId]/route.ts:
// app/api/gallery/[itemId]/route.ts
import { NextResponse } from 'next/server';
import { getGallery, mergeImages, setGallery, type GalleryImage } from '@/lib/webflow';
// No runtime export: Webflow Cloud does not support the Next.js edge runtime target.
const MAX_IMAGES = 25;
function safeEqual(a: string, b: string): boolean {
const enc = new TextEncoder();
const x = enc.encode(a);
const y = enc.encode(b);
let diff = x.length ^ y.length;
const len = Math.max(x.length, y.length);
for (let i = 0; i < len; i++) {
diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
}
return diff === 0;
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ itemId: string }> }
) {
const secret = process.env.GALLERY_SYNC_SECRET;
const provided = request.headers.get('x-gallery-secret') ?? '';
if (!secret || !safeEqual(provided, secret)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { itemId } = await params;
const body = (await request.json().catch(() => null)) as { images?: unknown } | null;
const raw = Array.isArray(body?.images) ? (body.images as GalleryImage[]) : [];
const incoming = raw.filter(
(img) =>
typeof img?.url === 'string' &&
img.url.startsWith('https://') &&
typeof img?.alt === 'string' &&
img.alt.length > 0
);
if (incoming.length === 0) {
return NextResponse.json(
{ error: 'Send at least one image with an https url and alt text' },
{ status: 400 }
);
}
try {
const first = await getGallery(itemId);
const merged = mergeImages(first.images, incoming);
if (merged.length > MAX_IMAGES) {
return NextResponse.json(
{ error: `A multi-image field holds at most ${MAX_IMAGES} images` },
{ status: 422 }
);
}
// Webflow has no conditional write, so re-read just before writing to narrow the race.
const check = await getGallery(itemId);
if (check.lastUpdated !== first.lastUpdated) {
return NextResponse.json({ error: 'Item changed during the update, retry' }, { status: 409 });
}
const after = await setGallery(itemId, merged);
return NextResponse.json({ itemId, before: first.images.length, after });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 502 });
}
}
If you later add a browser form that calls this route, keep the secret on a server you control, because anything shipped to the browser is public.
The handler returns the gallery count before the merge and the count Webflow stored after it, so a duplicate submission is easy to spot because the two match. Any image without an https URL and non-empty alt text is dropped, and a request with none left gets a 400.
A merge that would pass 25 images returns a 422, and an item that changed between the read and the write returns a 409 so the caller can retry. Failed Webflow calls come back as a 502 carrying Webflow's message.
Testing the local route
With npm run dev running, send a test POST to your local route:
curl -s -X POST "http://localhost:3000/api/gallery/ITEM_ID" \
-H "Content-Type: application/json" \
-H "x-gallery-secret: replace-with-a-long-random-string" \
-d '{"images":[{"url":"https://cdn.example.com/products/chair-side.jpg","alt":"Oak chair, side view"}]}'
This local call changes the real CMS item, so use your test item. After a successful write, remove the new image in the CMS so the live test starts from the same gallery.
6. Deploy and add an image through the live route
Follow Webflow Cloud's deployment instructions for your repository and mount the app at /gallery-sync in the environment settings. Add the four variables from .env.local to the deployment environment, storing WEBFLOW_API_TOKEN and GALLERY_SYNC_SECRET as secret variables, then deploy and wait until the deployment shows as live.
The app is mounted under the path you chose, so that path comes before /api in the URL. The route accepts image URLs only, because Webflow Cloud has no file system access; upload files to storage or your DAM first and send their public HTTPS URLs.
If the files live in Google Drive, the Drive image URLs guide shows how to serve them from your own domain. Your DAM or scheduled job will make this same call later, so testing it by hand now also tests the integration.
Verifying the live endpoint
Send the live POST, swapping in your own values:
curl -s -X POST "https://www.example.com/gallery-sync/api/gallery/ITEM_ID" \
-H "Content-Type: application/json" \
-H "x-gallery-secret: replace-with-a-long-random-string" \
-d '{"images":[{"url":"https://cdn.example.com/products/chair-side.jpg","alt":"Oak chair, side view"}]}'
The first call returns JSON such as {"itemId":"...","before":2,"after":3}, and the CMS item shows the new image last in its gallery. Read the item again with curl and confirm the two original entries kept their fileId values, which shows the write referenced them rather than uploading copies.
Then rerun the identical POST: after stays at 3 because the alt text matched the stored entry.
What causes Webflow API multi-image updates to fail?
Multi-image updates usually fail in four places: the build, the gallery write itself, concurrent syncs, and publishing.
Deploy fails on a Route Handler that builds locally
Cause: The Next.js edge runtime directive causes the build to fail because the OpenNext Cloudflare adapter that Webflow Cloud uses to deploy Next.js does not support that target. The bring-your-own-app page uses "edge" for two different things.
Webflow Cloud runs on Cloudflare Workers, an edge platform, and the page describes it this way: "Webflow Cloud deploys your app using the Edge runtime, enabling fast, globally distributed hosting." The Next.js edge runtime target is a separate setting. That page still tells you to add the directive to API routes.
Fix: Keep the routes compatible with Workers by deleting export const runtime = 'edge' from every Route Handler and page. I leave that export out because the routes already run on the Workers platform without it.
If a starter template or an AI coding assistant keeps reinserting the line, add a lint rule or a code-review check that flags the string before it reaches a deploy.
Route returns 502 when it writes the gallery
Cause: The 502 comes from the handler's catch block. On a write, the usual causes are an image URL Webflow cannot fetch, a file over 4 MB, or a WEBFLOW_GALLERY_FIELD that holds the field's label, the name editors see in the CMS, instead of its slug. With skipInvalidFiles=false, one bad URL fails the whole request with a 400 from Webflow, which the route reports as a 502
Fix: Read the error text inside the 502 body. If it begins with "Webflow update failed", it carries Webflow's status code and response text, which name the field or file that failed. Open each new image URL in a private browser window to confirm it is public and under 4 MB.
If an earlier response reported a before count of 0 on an item that visibly has images, correct WEBFLOW_GALLERY_FIELD to the field's slug in the Webflow Cloud environment and redeploy.
Gallery holds only the newest images after a sync
Cause: Two requests for the same item overlapped, or another script wrote the field with only its new entries. The overlap plays out like this with this route. Requests A and B arrive close together, and both call getGallery while the item holds 2 images.
A merges its one new image and PATCHes a 3-image list. B, still working from the 2 images it read, merges its own new image and PATCHes a different 3-image list. Because setGallery sends a complete list each time, B's list can replace A's, and the item ends with 3 images instead of 4. A's image is gone, even though both responses reported before 2 and after 3.
Fix: Keep each item's additions together in a single POST, since the images array accepts several entries and mergeImages adds them in one write.
Webflow's API has no conditional write, so the route can only narrow the window: it reads lastUpdated again just before the PATCH and returns a 409 if it changed. When a caller has to send several requests, have it await each one in turn instead of firing them together, and retry on a 409. Route every gallery write through this route, so no other script sends a partial list.
CMS shows the new images but the live page still shows the old gallery
Cause: A PATCH on the item writes the staged version, and the change reaches the live site only after the item or the site is published.
Fix: Repeat the curl GET on the item: if it shows the new entry, the write succeeded and only publishing remains. Publish the site from Webflow, or for automation call POST /v2/collections/{collection_id}/items/publish with {"itemIds":[itemId]} after setGallery, inside the same try block, so a failed publish surfaces as a 502. Reload the live page in a private window, because a cached tab can keep showing the old gallery.
What you can build next with the Webflow API and Webflow Cloud
A nightly supplier-photo sync is the natural next step, keeping a DAM as the source of truth while editors keep control of the gallery in Webflow. The bulk Update Items endpoint accepts up to 100 items per request, so one job can update many galleries in a few calls.
For syncing whole Collections with a database you own, the external database guide uses the same Data API.
For the publishing step and the rest of the item endpoints, Webflow's developer docs document every request this route could add.
Frequently asked questions
How do I remove or reorder images in the gallery?
Read the gallery with getGallery, filter out or reorder entries, then pass the complete array to setGallery. Send the whole array, not just the changed images, because each write replaces the stored list. Because the gallery route only appends, put removals in a second route guarded by the same secret.
How do I skip images that have not changed since the last sync?
Compare checksums before sending images. Webflow Cloud supports crypto.subtle.digest('MD5', ...) as a Cloudflare extension, so your sync can hash image bytes and skip those already sent. Use it only for change detection: Cloudflare warns, "MD5 is considered a weak algorithm. Do not rely upon MD5 for security."
Can I protect the route with middleware instead of a header check?
Yes, use middleware.ts, but I recommend retaining the header check as a second layer of authentication. Webflow Cloud supports only Edge runtime middleware on Workers. Although Next.js 16 renames middleware to proxy, keep middleware.ts: proxy.ts uses the Node runtime, cannot opt into Edge, and will not run on Webflow Cloud.
Does this build work on a site still showing an older plan name?
Yes. The build needs a site with the CMS and a Data API token, whatever the plan is called in your dashboard. Mounting the app on a custom domain is the only part that needs Premium or higher.
How many requests can this route handle on my plan?
Each request is capped at 20 seconds and 30 seconds of CPU on every plan, which this route stays well within. The tighter limit is the Data API's per-minute rate limit, since each request spends three calls; Webflow's limits page lists the Webflow Cloud figures.





