How to sync Webflow CMS with an external database on Webflow Cloud

How to sync Webflow CMS with an external database on Webflow Cloud

Learn how to build a Webflow Cloud Route Handler that pulls CMS items over the Data API and upserts changed rows into your database.

How to sync Webflow CMS with an external database on Webflow Cloud

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

CMS content rarely stays put; a Route Handler on Webflow Cloud can pull collection items via the REST API and keep external database rows current without a separate server.

The collection your marketing team edits in Webflow usually has to exist in other systems too. A reporting warehouse wants the same case studies, an internal tool reads the same product specs, and a search service needs the same field values in a schema it controls.

A CSV export or one-off copy script may work at first, but it quickly falls behind whatever an editor changes in the Designer.

A Next.js Route Handler deployed on Webflow Cloud can read collection items over Webflow's REST API, fingerprint each item, and upsert the rows into a database you already run. A shared secret gates the route, so any caller holding that secret, such as a scheduler or CI job, can trigger a sync on demand.

I keep the database side behind an HTTP endpoint on purpose: it needs nothing the runtime lacks, and it survives a future driver change.

Webflow Cloud runs the app on Cloudflare Workers through the OpenNext Cloudflare adapter, so the code behaves like a Workers runtime. That single fact decides which database client you can use and which Next.js directive will break the build.

What do you need to sync Webflow CMS with an external database?

You need a free Starter plan to get started. Only mounting the app on a custom domain requires Premium or higher. You can choose the database, as long as a Workers runtime can reach it; I would confirm compatibility before committing to a client.

Here are the requirements:

  • CMS collection to mirror: Choose the collection whose items you want to send to the external database.
  • Webflow API token: A Site API token with the CMS:read scope, from Site settings > Apps & integrations > API access, pasted into an environment variable.
  • Node.js 22 or later with npm: Webflow Cloud supports only the npm package manager, so pnpm or yarn commands in a build script produce steps that don't work.
  • Next.js 15 or higher: Webflow Cloud's bring-your-own Next.js app floor; Astro 6 or 7 and Vite 6.1 or higher also deploy.
  • HTTP-reachable database: A database that accepts writes over HTTP, with a table keyed on the Webflow item ID. The Workers runtime opens no raw TCP sockets, so pg and mysql2 pointed straight at a database will not connect; however, the build is configured.

The working routes are a serverless driver that speaks HTTP, the vendor's own REST layer, or Cloudflare Hyperdrive.

  • Sync secret and caller: A long random string for the secret, plus the scheduler or CI job that will call the route for unattended runs.

Once you have these in place, you can continue building.

6 steps to sync Webflow CMS to an external database in Webflow Cloud

Two small libraries handle secret verification and CMS fetching. A Route Handler uses them to hash each item and upsert it, with all code targeting the Workers runtime.

1. Scaffold the Next.js app

A default create-next-app project already has the right shape for Webflow Cloud. Webflow Cloud's Next.js docs put it as "No adapter, no base path, no wrangler.json." Confirm the installed version meets the floor listed above.

Run these two commands from the directory where you keep your repos:

npx create-next-app@latest webflow-cms-sync --typescript --app
cd webflow-cms-sync

You now have a TypeScript App Router project with an app/ directory and a package.json whose scripts use npm. Commit the scaffold as is; a clean npm run build locally is the outcome to check before moving on.

2. Add the environment variables

The handler reads five values from process.env, and three contain credentials. Set all five in both .env.local and the Webflow Cloud environment your app deploys to.

Webflow's environment variables docs state that both secret and non-secret variables are available to the build process and to the deployed application at runtime, so nothing here needs a separate build-time step. Secrets are redacted from build logs, which is why the token and the sync secret are marked as such.

The full set, with which ones to flag as secret:

Variable Secret What it holds
WEBFLOW_API_TOKEN Yes The Webflow API token this example includes in the CMS request's Authorization header.
WEBFLOW_COLLECTION_ID No The ID of the collection to sync.
DATABASE_UPSERT_URL No The HTTP endpoint on your database side that accepts a JSON batch of rows.
DATABASE_TOKEN Yes The credential that endpoint expects.
SYNC_SECRET Yes A long random string a caller must present in the x-sync-secret header.
Variable → Secret → What it holds
WEBFLOW_API_TOKEN
Yes
The Webflow API token this example includes in the CMS request's Authorization header.
WEBFLOW_COLLECTION_ID
No
The ID of the collection to sync.
DATABASE_UPSERT_URL
No
The HTTP endpoint on your database side that accepts a JSON batch of rows.
DATABASE_TOKEN
Yes
The credential that endpoint expects.
SYNC_SECRET
Yes
A long random string a caller must present in the x-sync-secret header.

Each row is a name your code will read from process.env. WEBFLOW_COLLECTION_ID lives in an environment variable for a practical reason: pointing the sync at a different collection becomes a settings change rather than a deploy.

This build needs far fewer variables so that you can add a second collection later by name

The deployed environment and your .env.local carry the same five names, so keep the spelling identical in both. Once the deployed environment includes all five with the correct secret settings, you're done.

3. Write the secret check

Local next dev lacks crypto.subtle.timingSafeEqual, a Cloudflare extension to Web Crypto, while the deployed Workers runtime provides it. A direct call therefore throws on your laptop.

The comparison must run in both environments. Hashing both sides first also guarantees equal-length buffers, which the constant-time comparison requires.

Save this as lib/verify-secret.ts:

const encoder = new TextEncoder();

async function sha256(value: string): Promise<ArrayBuffer> {
  return crypto.subtle.digest('SHA-256', encoder.encode(value));
}

export async function verifySecret(presented: string | null): Promise<boolean> {
  const expected = process.env.SYNC_SECRET;
  if (!presented || !expected) return false;

  // Hash both sides so the buffers are always the same length.
  const a = await sha256(presented);
  const b = await sha256(expected);

  const subtle = crypto.subtle as SubtleCrypto & {
    timingSafeEqual?: (x: ArrayBuffer, y: ArrayBuffer) => boolean;
  };

  if (typeof subtle.timingSafeEqual === 'function') {
    return subtle.timingSafeEqual(a, b);
  }

  // Local next dev: fall back to a byte comparison of the digests.
  const x = new Uint8Array(a);
  const y = new Uint8Array(b);
  let diff = 0;
  for (let i = 0; i < x.length; i++) diff |= x[i] ^ y[i];
  return diff === 0;
}

You now have a helper that returns true only when both secrets are present, and their digests match. Keep the typeof guard in place: it lets the same code path run in both environments without a build flag.

Import it in a scratch route and hit it with a wrong header; a false return with no thrown error is the outcome that confirms the fallback works locally.

4. Pull the CMS items

The Workers runtime has the standard fetch, so the CMS wrapper requires no SDK or Node-only HTTP client. The wrapper calls GET https://api.webflow.com/v2/collections/{collection_id}/items with a Bearer token carrying the CMS:read scope, and it has to page.

That endpoint returns 25 items when you pass no limit, and its documented maximum is 100, so a single unparameterized request against a 400-item collection returns 25 rows and reports no error. The response includes a pagination object with limit, offset and total, which the loop below uses to know when it is finished.

The same loop drops archived and draft items. Each item carries isArchived and isDraft, and a reporting warehouse that counts unpublished drafts as live content is a worse outcome than one that lags.

Create lib/webflow.ts with this content:

export type CmsItem = {
  id: string;
  lastUpdated?: string;
  isArchived?: boolean;
  isDraft?: boolean;
  fieldData: Record<string, unknown>;
};

type ItemsPage = {
  items?: CmsItem[];
  pagination?: { limit: number; offset: number; total: number };
};

const PAGE_SIZE = 100; // documented maximum for this endpoint

export async function fetchCmsItems(): Promise<CmsItem[]> {
  const collectionId = process.env.WEBFLOW_COLLECTION_ID;
  const token = process.env.WEBFLOW_API_TOKEN;
  if (!collectionId || !token) {
    throw new Error('WEBFLOW_COLLECTION_ID and WEBFLOW_API_TOKEN must be set');
  }

  const collected: CmsItem[] = [];
  let offset = 0;
  let total = Infinity;

  while (offset < total) {
    const url = new URL(
      `https://api.webflow.com/v2/collections/${collectionId}/items`,
    );
    url.searchParams.set('offset', String(offset));
    url.searchParams.set('limit', String(PAGE_SIZE));

    const res = await fetch(url, {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
      },
    });

    if (res.status === 429) {
      // Respect the documented Retry-After header rather than hammering.
      const wait = Number(res.headers.get('Retry-After') ?? '5');
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }

    if (!res.ok) {
      throw new Error(`Webflow API responded ${res.status}`);
    }

    const payload = (await res.json()) as ItemsPage;
    const page = payload.items ?? [];
    collected.push(...page.filter((item) => !item.isArchived && !item.isDraft));

    total = payload.pagination?.total ?? collected.length;
    if (page.length === 0) break; // defensive: never loop forever
    offset += page.length;
  }

  return collected;
}

This gives the Route Handler the whole collection, published items only, and a clear error when the token or collection ID is absent. The 429 branch matters more than it looks: the Data API allows 60 requests per minute on Starter and Basic and 120 on CMS, Ecommerce and Business, and Webflow's rate-limit page says the Retry-After header "will tell you how long to wait before attempting new requests."

A 900-item collection is nine sequential requests, which is comfortable, but a scheduled sync running alongside anything else that uses the same token is not. A quick check is to log collected.length in a scratch route and confirm it matches the published item count shown in the Designer.

5. Checksum and upsert the changed rows

SHA-256 from the standard WebCrypto API fingerprints each item's fieldData without a Node-only hashing library. The handler packages the items as rows keyed on the Webflow item ID and posts the batch to your database endpoint.

The database compares the incoming checksum with the stored one and only rewrites a row when they differ, so an editor fixing one typo leaves unrelated rows unchanged.

Put the handler at app/api/sync/route.ts:

import { verifySecret } from '../../../lib/verify-secret';
import { fetchCmsItems, type CmsItem } from '../../../lib/webflow';

type Row = {
  webflow_id: string;
  checksum: string;
  last_updated: string | null;
  data: Record<string, unknown>;
};

async function checksum(item: CmsItem): Promise<string> {
  const bytes = new TextEncoder().encode(JSON.stringify(item.fieldData));
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

async function upsertRows(rows: Row[]): Promise<void> {
  const url = process.env.DATABASE_UPSERT_URL;
  const token = process.env.DATABASE_TOKEN;
  if (!url || !token) {
    throw new Error('DATABASE_UPSERT_URL and DATABASE_TOKEN must be set');
  }

  const res = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ rows }),
  });
  if (!res.ok) {
    throw new Error(`Database responded ${res.status}`);
  }
}

export async function POST(request: Request) {
  const ok = await verifySecret(request.headers.get('x-sync-secret'));
  if (!ok) {
    return new Response(JSON.stringify({ error: 'unauthorized' }), {
      status: 401,
      headers: { 'Content-Type': 'application/json' },
    });
  }

  const items = await fetchCmsItems();
  const rows: Row[] = [];
  for (const item of items) {
    rows.push({
      webflow_id: item.id,
      checksum: await checksum(item),
      last_updated: item.lastUpdated ?? null,
      data: item.fieldData,
    });
  }

  await upsertRows(rows);
  return new Response(JSON.stringify({ synced: rows.length }), {
    headers: { 'Content-Type': 'application/json' },
  });
}

The route now rejects any caller without the right header. It then fetches the CMS items and posts rows containing their hashes in a single request. On the database side, make webflow_id the conflict key and write the row only when the stored checksum differs from the incoming one; in SQL, that is an upsert with a WHERE clause comparing checksums.

I store the whole fieldData object in a JSON column alongside the hash. This approach lets a field added in the Designer arrive in the database without a schema migration.

The security boundary is limited and explicit. The sync route authenticates callers with the server-side SYNC_SECRET and rejects a missing or incorrect header. Still, it has no rate limit, request-frequency cap, concurrency guard, or timestamp-and-nonce replay protection.

Configure the scheduler to avoid overlapping runs and add those controls if repeated or concurrent requests would be harmful. The database request sends DATABASE_TOKEN. The receiver implementation falls outside this example, so the database must implement and confirm token verification, authorization rules, rate limits, and replay handling.

No paid API is shown, so a spend cap isn't applicable here; OAuth state and webhook-signature verification also aren't applicable because this flow uses neither OAuth nor webhooks. SYNC_SECRET and DATABASE_TOKEN remain server-side and are not exposed through NEXT_PUBLIC_ variables.

Run npm run dev, post to http://localhost:3000/api/sync with the header, and look for a {"synced": N} response with rows visible in your database.

6. Deploy and run the first sync

A missing handler variable lets the build succeed but causes the route call to fail because the code reads the variables inside the handler. Confirm the variables from step 2 are set on the Webflow Cloud environment before deploying the app to your site.

This behavior is deliberate: a broken secret produces a 401 response you can diagnose while the build remains available for inspection.

Once the deploy finishes, trigger the route from your terminal. Substitute the domain and mount path Webflow Cloud shows for your app:

curl -X POST "https://your-domain.example/your-mount-path/api/sync" \
  -H "x-sync-secret: $SYNC_SECRET"

You should get {"synced": N} back, where N matches the number of items processed from the returned payload, and the rows should appear in your database with their checksums. Apply the complete-collection handling from step 4 before expecting a complete-collection count.

Run the command a second time without changing anything in Webflow: the response should be identical, and the database row timestamps should stay fixed, which proves the checksum guard works.

Then edit one item in the Designer, publish, run it a third time, and only that row's checksum and data should change.

A successful JSON response confirms the request reached the handler and completed. Point your scheduler or CI job at the same command with the same header, and the sync runs unattended from here.

What causes a Webflow CMS database sync to fail?

A sync can fail before its handler runs when the build targets an incompatible runtime or imports an unavailable API. Other failures surface only in local requests or after a framework migration.

Build logs and stack traces reveal distinct symptoms, and each fix stays inside the constraints Webflow Cloud sets. Start with the runtime mismatch because it can make otherwise valid application code fail before the sync runs.

Deployment fails with an OpenNext adapter build error

Cause: Adding export const runtime = 'edge' selects an incompatible Next.js runtime target. The confusion is understandable because "edge" means two things on the same docs page. The docs describe the hosting platform as deploying your app using the Edge runtime.

The bring-your-own-app page then goes further and says to add the directive to your API route. Following that, the sentence breaks the build.

Fix: Remove the directive from every Route Handler and page, and remove any runtime segment config you added while reading the docs. Handlers execute on the Workers deployment described above after you remove the file-level runtime configuration.

Rebuild locally with npm run build and confirm it passes before redeploying. If a teammate re-adds the line later because they read the same page, explain the platform-versus-runtime distinction so the line stays out.

Local next dev throws subtle.timingSafeEqual is not a function

This error points to a direct crypto.subtle.timingSafeEqual call that bypasses the cross-environment helper from step 3.

Search the app for timingSafeEqual and route any older direct call through verifySecret. Node's own crypto.timingSafeEqual from node:crypto is a different function and is not the one missing here. A 401 with a wrong header and a 200 with the right one, both under npm run dev, confirms the change.

Middleware stops running after renaming middleware.ts to proxy.ts

Next 16 renames middleware to proxy, and following the upgrade guide produces a proxy.ts that cannot execute on Webflow Cloud. Webflow's framework-customization docs are explicit: "Node.js runtime middleware isn't supported.

Only Edge runtime middleware works on the Workers runtime." The proxy file runs on the Node runtime and cannot opt into Edge, so the rename yields a file that the Workers runtime never runs.

If middleware rejects requests without the sync header before they reach the handler, that protection disappears, and the only visible sign is that the handler now sees every request.

Pin Next.js 15 for any middleware-dependent design, and keep the file named middleware.ts with its existing Edge-compatible export. Declining the codemod on Next 16 is not enough on its own, because the middleware convention is deprecated there.

The Route Handler at app/api/sync/route.ts already checks the secret itself, so the app stays safe even without middleware; middleware provides a convenience layer here. After redeploying, send a request with no header and confirm it is rejected at whichever layer you intended.

The build fails on a database driver that imports node:fs

node:fs is unavailable on Webflow Cloud. Webflow Cloud's docs pin a Workers compatibility date of 2025-04-15, and node:fs requires 2025-09-01 or later, so any dependency that reads a certificate bundle or a migration directory from disk fails at build or on first import.

The failure comes from the APIs the runtime exposes. node:path is available, which is why a package that only manipulates paths installs fine while one that opens files does not, and that partial success makes the error hard to place.

Route database writes through an HTTP endpoint, as the upsertRows function does, or pick a driver whose documentation states it runs on Cloudflare Workers without filesystem access. Search the failing build log for node:fs to identify the offending package, then check whether it offers a Workers or fetch-based entry point.

If the only option needs the filesystem, keep the driver on the database side of the HTTP boundary, where it runs in whatever environment hosts your endpoint, and leave the Webflow Cloud app as a pure fetch client.

What you can build next with an external database and Webflow

Once your collection lands in a database you control, the questions marketing has been asking of the CMS become queries: which case studies changed this quarter, and which products are missing a field.

I would start with the query that removes the most manual checking from the team's existing content workflow. The Webflow CMS stays the editing surface where the team ships pages visually, and the sync makes that content available to every other system on your side without a second content workflow.

The same Route Handler shape drives a Notion database sync when the system of record sits entirely outside Webflow. For a database-specific option, explore the PostgreSQL integration.

Frequently asked questions

Deletion behavior and credential handling deserve the first decisions because they determine whether the resulting sync stays accurate and secure. Extensions can run in both directions, use a different checksum, handle deleted items, or add a site trigger as long as each path respects the Workers runtime and keeps secrets server-side.

Can I sync from the external database back into Webflow CMS?

Yes. The Data API creates items with POST /v2/collections/{collection_id}/items and updates them with PATCH /v2/collections/{collection_id}/items/{item_id}, using a token with the CMS:write scope rather than the read-only one this build uses. Build a second Route Handler that reads rows from your database and calls those endpoints. It follows the same deployment constraints as the pull, including the same rate limit.

Should I use MD5 for the checksum instead of SHA-256?

You can, though SHA-256 offers the stronger default. MD5 is available on Webflow Cloud through crypto.subtle.digest('MD5', ...) as a Cloudflare Workers extension, though it is non-standard, and Cloudflare's own note reads, "MD5 is considered a weak algorithm. Do not rely upon MD5 for security." For a change-detection fingerprint, it works; SHA-256 is standard everywhere.

Can I add a button on the site that triggers the sync?

You can, with two cautions. Client-side fetch calls must manually include the base path because Webflow Cloud does not rewrite them, so pass the mount path through NEXT_PUBLIC_BASE_PATH. A button in a browser cannot hold SYNC_SECRET safely, so have it call a separate authenticated route that checks the user's session before invoking the sync server-side.


Last Updated
September 25, 2026
Category

Related articles


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

Get started for free

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
Watch demo

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.