Turn author names and topic tags into working Webflow CMS references with one Webflow Cloud route.
Imported content often includes human-readable details like "written by Priya Shah" or "tagged pricing, onboarding," but Webflow CMS Reference fields require item IDs rather than text names.
Linking a few articles manually in the Designer is simple, but bulk imports from spreadsheets or external feeds require an automated system to convert text values into valid CMS item IDs.
In this guide, we’ll build a Next.js Route Handler on Webflow Cloud that resolves author and topic slugs to item IDs, writes them through the Data API, and publishes the result. I key on slugs because editors rename display names far more often.
What do you need to link CMS items through the Webflow API?
Here are five requirements that you need:
- Webflow site: On the Premium Site plan or higher, you need to mount the app on your custom domain.
- Webflow Cloud: Enabled on that site.
- Next.js 15 app: Created with
npx create-next-app@15and managed with npm, because Webflow Cloud supports only the npm package manager. - Local development: Node.js 22 or later on your machine for local development.
- Site access and Collections: Site-level access that lets you generate an API token in Site settings, plus an Articles Collection, an Authors Collection, and a Topics Collection with a few items each.
With these prerequisites in place, you are ready to configure the environment and start building the integration.
6 steps to link CMS items with the Webflow API in Webflow Cloud
The build is a small Data API client and one Route Handler. The handler checks a shared secret, then resolves slugs to item IDs before it writes and publishes the article's Author and Topics fields.
1. Generate a site API token with CMS access
Create a token scoped to this one site before touching the schema. I use it to confirm field slugs in the next step. In Site settings, find the API access area and generate a site API token with read and write access to the CMS.
Give it a name that says what it is for, such as "CMS linking route." Copy the token right away and store it somewhere private.
The route needs read access to list items and resolve slugs, and write access to update fields and publish. If you build for a client, generate the token from the client's site and record who owns it.
If you regenerate the token, update the WEBFLOW_API_TOKEN environment variable you set in the Webflow Cloud environment. Export the saved token in your shell for testing.
2. Add Reference fields and read them back from the schema
The Articles Collection needs a Reference field named Author that points at Authors and a Multi-reference field named Topics that points at Topics. Add both in the Articles Collection's settings, and copy all three Collection IDs from each Collection's settings in the CMS panel.
I confirm the two fields' slugs against the Collection schema because this route uses author and topics as keys, not display names.
Run this request with your site API token to read the Collection schema from Get Collection:
export WEBFLOW_API_TOKEN="paste-your-token"
export ARTICLES_COLLECTION_ID="paste-articles-collection-id"
curl -s "https://api.webflow.com/v2/collections/$ARTICLES_COLLECTION_ID" \
-H "Authorization: Bearer $WEBFLOW_API_TOKEN" \
-H "accept: application/json"
In the response, find the author and topics fields and check their type and target Collection. Per Webflow's field types reference, a Reference field takes one item ID as a string and a Multi-reference field takes an array of item ID strings, which is what the route sends:
| Field type | Shape the route sends | How the route builds it |
|---|---|---|
| Reference (single item) | One item ID as a string | Sends the one resolved item ID |
| Multi-reference | An array of item ID strings | Merges existing IDs with the new ones before writing |
| Field type → Shape the route sends → How the route builds it |
|---|
| Reference (single item) |
| One item ID as a string |
| Sends the one resolved item ID |
| Multi-reference |
| An array of item ID strings |
| Merges existing IDs with the new ones before writing |
If your schema shows slugs other than author and topics, change the keys fieldData.author and fieldData.topics in route.ts to match before deploying.
3. Store the token and Collection IDs as Webflow Cloud environment variables
Move every credential and ID out of code and into the Webflow Cloud environment that runs the app.
Webflow Cloud's environment variables documentation states: "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime." Secrets are redacted from build logs, which is why the token and route secret get the secret setting.
Add each variable to the environment your Webflow Cloud app deploys from, and mirror the same values in a local .env.local file for next dev.
Reading the required environment variables
The route reads five variables:
- WEBFLOW_API_TOKEN: The site API token, stored as a secret so build logs redact it; omitting the
NEXT_PUBLIC_prefix keeps it out of browser code. - LINK_ROUTE_SECRET: A long random string you generate yourself; callers send it in a header so strangers cannot rewrite your CMS relationships.
- ARTICLES_COLLECTION_ID: The ID of the Collection holding the Author and Topics fields, which is the item the route updates.
- AUTHORS_COLLECTION_ID: The Collection the Author field points to, used to resolve an author slug into the item ID the field stores.
- TOPICS_COLLECTION_ID: The Collection behind the Topics field, used to resolve each topic slug before merging IDs into the existing list.
I keep these variables server-side by leaving NEXT_PUBLIC_ out of their names. Next.js exposes variables with that prefix to browser code; using it for the token would give anyone viewing the source write access to your CMS. Once saved, the route has everything it needs at runtime.
4. Write a small Data API client for CMS items
Create lib/webflow.ts to keep the Data API calls in one module. I group them here so the Route Handler stays readable and every request shares the same auth and error handling. It uses plain fetch, so no SDK install is needed.
The client covers the three calls the route needs:
// lib/webflow.ts
const API_BASE = "https://api.webflow.com/v2";
export type CmsItem = {
id: string;
isDraft: boolean;
isArchived: boolean;
fieldData: Record<string, unknown> & { slug: string; name: string };
};
type ListItemsResponse = {
items: CmsItem[];
pagination: { limit: number; offset: number; total: number };
};
function authHeaders(): Record<string, string> {
const token = process.env.WEBFLOW_API_TOKEN;
if (!token) throw new Error("WEBFLOW_API_TOKEN is not set");
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
accept: "application/json",
};
}
async function webflowRequest<T>(
path: string,
init: { method?: string; body?: string } = {}
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: init.method ?? "GET",
body: init.body,
headers: authHeaders(),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`Webflow API ${res.status} on ${path}: ${detail}`);
}
return (await res.json()) as T;
}
// Returns the one item with this slug, using the API's exact-match slug filter.
export async function itemBySlug(
collectionId: string,
slug: string
): Promise<CmsItem | undefined> {
const page = await webflowRequest<ListItemsResponse>(
`/collections/${collectionId}/items?slug=${encodeURIComponent(slug)}&limit=1`
);
return page.items[0];
}
export async function updateItemFields(
collectionId: string,
itemId: string,
fieldData: Record<string, unknown>
): Promise<CmsItem> {
return webflowRequest<CmsItem>(
`/collections/${collectionId}/items/${itemId}`,
{ method: "PATCH", body: JSON.stringify({ fieldData }) }
);
}
export async function publishItems(
collectionId: string,
itemIds: string[]
): Promise<unknown> {
if (itemIds.length === 0) return null;
return webflowRequest(`/collections/${collectionId}/items/publish`, {
method: "POST",
body: JSON.stringify({ itemIds }),
});
}
The update uses PATCH and sends only the Author and Topics fields, so the request never touches the article's other fields. Each lookup uses the List Items endpoint's exact-match slug filter, so a typical request costs a handful of small lookups no matter how large the Collections are.
That matters because every call counts against the Data API's per-minute rate limit, which the 429 errors guide covers. A thrown error carries the exact status and response body from Webflow.
5. Build the Route Handler that writes and publishes the links
Create the endpoint that resolves slugs and merges the Topics list before it writes and publishes the article. Add app/api/link/route.ts, and leave out any runtime export. Webflow Cloud runs on Cloudflare Workers, an edge platform, but the Next.js edge runtime target is a separate setting that the OpenNext Cloudflare adapter does not support.
The handler compares the x-link-secret header in constant time and keeps the token on the server. It has no rate limit, so call it only from trusted tools:
// app/api/link/route.ts
import { NextResponse } from "next/server";
import { itemBySlug, updateItemFields, publishItems } from "@/lib/webflow";
type LinkRequest = {
articleSlug: string;
authorSlug?: string;
topicSlugs?: string[];
};
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;
}
function env(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
}
export async function POST(request: Request) {
const secret = process.env.LINK_ROUTE_SECRET ?? "";
const provided = request.headers.get("x-link-secret") ?? "";
if (!secret || !safeEqual(provided, secret)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: LinkRequest;
try {
body = (await request.json()) as LinkRequest;
} catch {
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
}
if (!body.articleSlug) {
return NextResponse.json({ error: "articleSlug is required" }, { status: 400 });
}
try {
const articlesId = env("ARTICLES_COLLECTION_ID");
const authorsId = env("AUTHORS_COLLECTION_ID");
const topicsId = env("TOPICS_COLLECTION_ID");
const topicSlugs = body.topicSlugs ?? [];
const [article, author, topicItems] = await Promise.all([
itemBySlug(articlesId, body.articleSlug),
body.authorSlug ? itemBySlug(authorsId, body.authorSlug) : Promise.resolve(undefined),
Promise.all(topicSlugs.map((slug) => itemBySlug(topicsId, slug))),
]);
if (!article) {
return NextResponse.json({ error: `Unknown article: ${body.articleSlug}` }, { status: 404 });
}
const fieldData: Record<string, unknown> = {};
const authorIds: string[] = [];
const topicIds: string[] = [];
if (body.authorSlug) {
if (!author) {
return NextResponse.json({ error: `Unknown author: ${body.authorSlug}` }, { status: 404 });
}
fieldData.author = author.id;
authorIds.push(author.id);
}
if (topicSlugs.length) {
const missing = topicSlugs.filter((_, i) => !topicItems[i]);
if (missing.length) {
return NextResponse.json({ error: `Unknown topics: ${missing.join(", ")}` }, { status: 404 });
}
for (const item of topicItems) topicIds.push(item!.id);
const existing = Array.isArray(article.fieldData.topics)
? (article.fieldData.topics as string[])
: [];
fieldData.topics = Array.from(new Set([...existing, ...topicIds]));
}
if (Object.keys(fieldData).length === 0) {
return NextResponse.json({ error: "Nothing to link" }, { status: 400 });
}
await updateItemFields(articlesId, article.id, fieldData);
// Publish the linked authors and topics, then the article.
await publishItems(authorsId, authorIds);
await publishItems(topicsId, topicIds);
await publishItems(articlesId, [article.id]);
return NextResponse.json({ linked: body.articleSlug, fieldData });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 502 });
}
}
I wrote the comparison by hand because timingSafeEqual exists on the deployed Workers runtime but not under local next dev, so a route using it passes in production and crashes on your laptop, or the reverse. The Set merge is what keeps existing topics. The route also passes every linked author and topic ID to publishItems.
Publishing those items pushes any other staged edits on them live as well. If editors stage changes on Authors or Topics, delete those two publishItems calls and publish only the article. A correct request now updates the article's fields and publishes the article in one request to the route.
6. Test the deployed route under its mount path
Call the deployed route with a real article plus its author and two topics, using the full URL including the mount path you chose for the Webflow Cloud app. I use the mount path plus /api/link, where the route lives, rather than /api/link on the site root. Set APP_URL to your site domain plus the mount path, with no trailing slash.
Send this request from your terminal, swapping in slugs that exist in your Collections:
export APP_URL="https://www.example.com/content-tools"
export LINK_ROUTE_SECRET="paste-your-route-secret"
curl -s -X POST "$APP_URL/api/link" \
-H "Content-Type: application/json" \
-H "x-link-secret: $LINK_ROUTE_SECRET" \
-d '{"articleSlug":"pricing-update","authorSlug":"priya-shah","topicSlugs":["pricing","onboarding"]}'
A success returns JSON with linked set to the article slug and a fieldData object showing one author ID and the merged topic IDs. Run the same request a second time and the topics array should not grow, because the merge removes duplicates.
Then open the article in the CMS panel: Author shows Priya Shah, Topics lists both tags plus any that were there before, and, if your Articles template binds the Author and Topics fields, the live Collection page renders them.
What causes Webflow API CMS links to fail?
Failures fall into four groups: values the Data API rejects as invalid references, builds broken by the Next.js edge runtime export, requests that miss the app's mount path, and links that save but never reach the live site.
Route returns 502 with a Webflow error on the author or topics field
Cause: The value in fieldData is not an item ID from the Collection that field points to. The usual culprits are sending a name or slug instead of an ID, using an ID from a different Collection, keying the value by display name (Author) instead of slug (author), or sending a single string to a Multi-reference field that expects an array.
Fix: I start with the 502 response body, which carries the status and error text Webflow sent back. Rerun the GET request on the Articles Collection and compare the Collection ID each relationship field points to with AUTHORS_COLLECTION_ID and TOPICS_COLLECTION_ID in your environment.
If AUTHORS_COLLECTION_ID names a different Collection than the one the Author field targets, the slug lookup may resolve an item from the wrong Collection; check that ID against the field's target. Swapping the Authors and Topics IDs can fail earlier with a 404 Unknown author response if the requested author slug is not found. Confirm that the Topics value is an array even when you link only one topic.
Webflow Cloud build fails on a route that builds locally
Cause: Webflow's bring-your-own-app page tells you to add the Next.js edge runtime directive to API routes, and following it breaks the build.
Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime target. The confusion comes from the word "edge" meaning two things on the same page: Webflow Cloud runs on Cloudflare Workers, an edge platform, while export const runtime = 'edge' is a Next.js setting the adapter cannot build.
Fix: Delete the runtime export from every route file and redeploy. I use the default Next.js runtime, which the adapter builds when the export is absent. If you also protect routes with middleware, keep the file named middleware.ts; renaming it to proxy.ts under Next 16 moves it to the Node runtime, which Webflow Cloud does not support for middleware.
The route returns 404 from the app's own pages but works with curl
Cause: The mount path is injected at build time, and client-side fetch calls are not rewritten to include it. A browser call to /api/link from a page inside your app resolves against the site root, outside the app's mount path, so the request never reaches the route. The curl test passes because you typed the mount path into APP_URL yourself.
Fix: Add a non-secret environment variable named NEXT_PUBLIC_BASE_PATH holding the mount path, such as /content-tools, since Webflow Cloud does not create it, and build client request URLs as ${process.env.NEXT_PUBLIC_BASE_PATH}/api/link. Server-side calls inside the app are unaffected. Keep the route secret on the server, and call it only from the server or a trusted internal tool.
Links appear in the CMS panel but not on the published site
Cause: The field write and the publish calls are separate requests. The route sends the PATCH through updateItemFields first, then makes three publishItems calls. If the PATCH succeeds and one of those publish calls throws, the route returns a 502 after the field write has already landed. The CMS panel then shows the new Author and Topics; expect the live page to keep showing the last published version until a publish call succeeds.
Fix: Read the 502 body for Webflow's error text and the request path. I use the Collection ID in that path to identify whether Authors, Topics, or Articles failed to publish. Once the cause is fixed, rerun the same request; the Set merge makes the retry safe, because topics will not duplicate.
What you can build next with the Webflow API and Webflow CMS
The same slug-to-ID resolution is the core of a scheduled import that links each new article as it lands, or a cleanup job that removes a retired topic from every Multi-reference list at once. For moving whole Collections in and out of Webflow, the external database guide uses the same Data API.
List Items now also filters on custom fields with filter[<slug>][<op>], so a lookup by any unique field, such as an author's email, works the same way as the slug filter in this route.
For deeper customization beyond what Reference and Multi-reference fields handle natively, Webflow's developer docs cover publishing items and the rest of the Data API.
Frequently asked questions
Do I need Webflow Cloud to link CMS items through the API?
No. Any server that can hold a token and send HTTPS requests can call the Data API, including a local script. Webflow Cloud is convenient because the route deploys alongside your site and reads secrets from its environment, so you avoid running a separate server for one endpoint.
Can I protect the linking route with Next.js middleware instead of a header check?
Yes, as long as the file is named middleware.ts. Webflow Cloud supports only Edge runtime middleware on Workers. Next 16 renames middleware to proxy.ts, but that file runs on the Node runtime and cannot opt into Edge, so a renamed file will not run. I recommend keeping the original name when you upgrade Next.js.
How do I remove a topic from an article?
To remove a topic, read the article's current topics array, filter out the unwanted item ID, and send the trimmed array through updateItemFields. Then call publishItems for the article so the live page drops the topic. Sending my route fewer topic slugs never removes one, because it only merges new IDs into the article's existing list.
Can the route read a CSV of links from disk to process in bulk?
No. Webflow Cloud's Workers runtime has no file system access, as its Node.js compatibility page notes, so the route cannot read a CSV from disk. Send the rows in the request body or fetch the file over HTTPS, then run the same logic for each row.





