How to sync Webflow CMS data with Google Sheets

How to sync Webflow CMS collections to Google Sheets with a Webflow Cloud route

Learn how to build a Webflow Cloud route that copies a CMS collection into Google Sheets.

How to sync Webflow CMS collections to Google Sheets with a Webflow Cloud route

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

A Webflow Cloud route can copy a CMS collection into Google Sheets whenever you call it, so the people who need the data stop asking for CSV exports.

Exporting blog data to Google Sheets helps non-Webflow users, like content leads and SEO specialists, audit titles and URLs without needing Designer access. Static CSV exports quickly become outdated. A dedicated sync keeps the sheet updated with the latest CMS data, including draft status and publication dates.

Webflow's Data API exposes every CMS collection over REST, so a server can read what you edit visually. In this guide, we’ll build a Next.js Route Handler on Webflow Cloud that copies one collection into one sheet tab on each authenticated call.

What do you need to sync Webflow CMS data with Google Sheets in Webflow?

You need five things before you begin:

  • Webflow site: A site with at least one CMS collection that already contains items, plus Webflow Cloud, which is available from Starter up.
  • Webflow API token: A site token with CMS read permission, plus the collection ID.
  • Google service account: A Cloud project with the Sheets API enabled, a service account with a JSON key, and a spreadsheet shared with its email.
  • Local toolchain: Node.js 22 or later, Next.js 15 or higher, and npm, the only package manager the bring your own app docs list as supported.
  • Shared secret: A long random string that gates the route; anyone holding it can read the collection and overwrite the sheet.

With those in hand, the build is a single Route Handler plus a handful of environment variables, and the sheet fills on the first authenticated request.

6 steps to sync Webflow CMS data with Google Sheets in Webflow Cloud

The build is one POST route in a Next.js app on Webflow Cloud that pulls the items returned for a collection and rewrites a sheet. Google authentication uses Web Crypto because the deployed route runs on Cloudflare Workers.

I set up credentials first (Google, then Webflow), follow with the app and its route, and finish with deployment plus a live trigger.

1. Create the Google service account and share the sheet

Create a service account rather than an OAuth client, because the route runs server-to-server with nobody present to click through a consent screen. In the Google Cloud console, pick or create a project, enable the Google Sheets API, create a service account, and download a JSON key for it. The file contains two values the route needs: client_email and private_key.

Now open the spreadsheet you want to fill, click Share, and paste the service account's client_email address with the Editor role. People skip sharing, and without it, every write returns a permission error even though the token exchange succeeded.

Copy the spreadsheet ID from the URL, the long string between /d/ and /edit, and note the tab name you want written (a new spreadsheet uses Sheet1). You should now have the service account email, the private key text, the spreadsheet ID, and a sheet tab the account can edit.

2. Generate the Webflow API token and copy the collection ID

Set up read access to CMS content before touching the collection itself. Generate a Webflow API token and grant it CMS read permission only; the route never writes to Webflow, so a token that can write is unnecessary risk. Copy the token into a password manager right away.

Next, locate the Collection ID for the collection. While you are in the collection, write down the slugs of the fields you want in the sheet. The route uses the Collection ID to request CMS data and a comma-separated list of field slugs to decide which columns to emit. Common field slugs include name and slug.

Keep each slug exactly as Webflow defines it, because the route looks up item.fieldData[f] by that string and writes an empty cell when no matching value is present.

At this point you hold a read-only token, a collection ID, and a short list of field slugs to export.

3. Scaffold the Next.js app on npm

Scaffold a plain Next.js app with TypeScript and the App Router, and use npm throughout. Webflow Cloud supports only npm, and it doesn't support install steps written as pnpm add or yarn add, so I never let another package manager near one of these projects, even locally.

Run the scaffold from an empty directory:

npx create-next-app@15 cms-sheets-sync --typescript --app --no-tailwind --eslint
cd cms-sheets-sync
npm run dev

The default page should load on http://localhost:3000. Leave next.config alone: Webflow Cloud's configuration docs say you do not need to add an adapter, a base path, or an output mode, and the mount path is injected at build time.

You also don't need to install anything on the Google side. The route uses only the Fetch API and the Web Crypto crypto.subtle interface, both standard web APIs rather than Node modules, which keeps the dependency list empty and avoids guessing which Node modules a third-party client might reach for once deployed.

One line to avoid, even though a live docs page still suggests it: do not add export const runtime = 'edge' to the route. That directive targets the Next.js edge runtime, which the OpenNext Cloudflare adapter cannot build.

You now have a running Next.js 15 (or later) app with no extra dependencies, ready for the route file.

4. Write the sync Route Handler

Write a single POST handler that checks the shared secret, pages through the collection, exchanges a signed JWT (JSON Web Token) for a Google access token, and writes the rows. A values update writes only the cells in its payload, so a collection that shrank from 40 items to 30 would leave 10 stale rows at the bottom.

The route therefore writes first and then clears everything below the new last row, so a failed write leaves the previous sync in place rather than an empty tab.

The route authenticates every call with a shared secret, compares it in constant time, and keeps all credentials server-side. Add rate limiting upstream before you share the URL widely.

Create app/api/sync/route.ts with this content:

// app/api/sync/route.ts
import { NextResponse } from "next/server";

const SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";

function base64url(input: ArrayBuffer | string): string {
  const bytes =
    typeof input === "string"
      ? new TextEncoder().encode(input)
      : new Uint8Array(input);
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function pemToBuffer(pem: string): ArrayBuffer {
  const body = pem
    .replace("-----BEGIN PRIVATE KEY-----", "")
    .replace("-----END PRIVATE KEY-----", "")
    .replace(/\s+/g, "");
  const binary = atob(body);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes.buffer;
}

// Hand-rolled because timingSafeEqual exists on the deployed Workers
// runtime but not under local `next dev`.
function constantTimeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

async function getGoogleAccessToken(): Promise<string> {
  const email = process.env.GOOGLE_CLIENT_EMAIL!;
  const pem = process.env.GOOGLE_PRIVATE_KEY!.replace(/\\n/g, "\n");
  const now = Math.floor(Date.now() / 1000);

  const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
  const claims = base64url(
    JSON.stringify({
      iss: email,
      scope: SHEETS_SCOPE,
      aud: "https://oauth2.googleapis.com/token",
      iat: now,
      exp: now + 3600,
    })
  );

  const key = await crypto.subtle.importKey(
    "pkcs8",
    pemToBuffer(pem),
    { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
    false,
    ["sign"]
  );
  const signature = await crypto.subtle.sign(
    "RSASSA-PKCS1-v1_5",
    key,
    new TextEncoder().encode(`${header}.${claims}`)
  );
  const assertion = `${header}.${claims}.${base64url(signature)}`;

  const res = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
      assertion,
    }),
  });
  if (!res.ok) {
    throw new Error(`Google token exchange failed: ${res.status} ${await res.text()}`);
  }
  const data = (await res.json()) as { access_token: string };
  return data.access_token;
}

type WebflowItem = {
  id: string;
  lastPublished: string | null;
  isDraft: boolean;
  isArchived: boolean;
  fieldData: Record<string, unknown>;
};

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

async function fetchAllItems(collectionId: string, token: string): Promise<WebflowItem[]> {
  const items: WebflowItem[] = [];
  let offset = 0;
  while (true) {
    const res = await fetch(
      `https://api.webflow.com/v2/collections/${collectionId}/items?limit=100&offset=${offset}`,
      { headers: { Authorization: `Bearer ${token}`, accept: "application/json" } }
    );
    if (!res.ok) {
      throw new Error(`Webflow API failed: ${res.status} ${await res.text()}`);
    }
    const page = (await res.json()) as ItemsPage;
    items.push(...page.items);
    offset += page.pagination.limit;
    if (page.items.length === 0 || offset >= page.pagination.total) break;
  }
  return items;
}

function toCell(v: unknown): string {
  if (v === null || v === undefined) return "";
  return typeof v === "object" ? JSON.stringify(v) : String(v);
}

export async function POST(request: Request) {
  const provided = request.headers.get("x-sync-secret") ?? "";
  const expected = process.env.SYNC_SECRET ?? "";
  if (!expected || !constantTimeEqual(provided, expected)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const required = [
    "WEBFLOW_API_TOKEN",
    "WEBFLOW_COLLECTION_ID",
    "GOOGLE_CLIENT_EMAIL",
    "GOOGLE_PRIVATE_KEY",
    "GOOGLE_SPREADSHEET_ID",
  ];
  const missing = required.filter((name) => !process.env[name]);
  if (missing.length) {
    return NextResponse.json({ error: "Missing variables", missing }, { status: 500 });
  }

  const items = await fetchAllItems(
    process.env.WEBFLOW_COLLECTION_ID!,
    process.env.WEBFLOW_API_TOKEN!
  );

  const fields = (process.env.SHEET_FIELDS ?? "name,slug")
    .split(",")
    .map((f) => f.trim())
    .filter(Boolean);

  const rows: string[][] = [
    ["id", "lastPublished", "isDraft", "isArchived", ...fields],
    ...items.map((item) => [
      item.id,
      item.lastPublished ?? "",
      String(item.isDraft),
      String(item.isArchived),
      ...fields.map((f) => toCell(item.fieldData[f])),
    ]),
  ];

  const accessToken = await getGoogleAccessToken();
  const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID!;
  const sheetName = process.env.GOOGLE_SHEET_NAME ?? "Sheet1";
  // Quote the tab name so names with spaces, or names like "A1", still parse.
  const tab = `'${sheetName.replace(/'/g, "''")}'`;
  const values = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values`;
  const authHeaders = {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  };

  // Write first, so a failed write leaves the previous sync in place.
  const written = await fetch(
    `${values}/${encodeURIComponent(`${tab}!A1`)}?valueInputOption=RAW`,
    {
      method: "PUT",
      headers: authHeaders,
      body: JSON.stringify({ range: `${tab}!A1`, majorDimension: "ROWS", values: rows }),
    }
  );
  if (!written.ok) {
    throw new Error(`Sheets update failed: ${written.status} ${await written.text()}`);
  }

  // Then clear any rows left over from a larger earlier sync.
  const leftover = `${tab}!A${rows.length + 1}:ZZ`;
  const cleared = await fetch(`${values}/${encodeURIComponent(leftover)}:clear`, {
    method: "POST",
    headers: authHeaders,
    body: "{}",
  });
  if (!cleared.ok) {
    throw new Error(`Sheets clear failed: ${cleared.status} ${await cleared.text()}`);
  }

  return NextResponse.json({ synced: items.length, columns: rows[0].length });
}

The private key arrives from an environment variable and goes through .replace(/\\n/g, "\n"), because pasting a PEM (the text-encoded key format) into a settings form can turn its line breaks into literal backslash-n pairs, and a key in that state no longer decodes as PEM.

Image and file fields arrive as objects, so toCell writes them as JSON text instead of "[object Object]"; multi-reference fields arrive as arrays of item IDs and get the same treatment.

Requesting 100 items per page, the maximum, keeps a large collection within Webflow's per-minute API rate limit and reduces round trips within the request timeout. The tab name is quoted so names with spaces still parse as a range.

Start npm run dev and send this request to confirm the route compiles and rejects a bad secret:

curl -i -X POST http://localhost:3000/api/sync -H "x-sync-secret: wrong"

A 401 with {"error":"Unauthorized"} is the correct result here; the full run needs the environment variables set.

5. Add the environment variables and deploy to Webflow Cloud

Set the eight environment variables below in .env.local so next dev picks them up, and again in the Webflow Cloud environment, flagging the three sensitive values as secrets.

In Webflow Cloud, secret and non-secret variables are both available to the build process and to the deployed app at runtime, and secrets are redacted from build logs.

That last detail is why the private key belongs in a secret variable and never in a file checked into the repo, even though the route couldn't read a file on Workers anyway.

<>

Use these names, with the three sensitive ones flagged as secrets:

Variable Value Secret?
WEBFLOW_API_TOKEN The read-only site token Yes
WEBFLOW_COLLECTION_ID The collection ID from the collection's settings No
SHEET_FIELDS Comma-separated field slugs, for example name,slug,post-summary No
GOOGLE_CLIENT_EMAIL client_email from the service account JSON No
GOOGLE_PRIVATE_KEY private_key from the service account JSON, including the BEGIN and END lines Yes
GOOGLE_SPREADSHEET_ID The ID from the spreadsheet URL No
GOOGLE_SHEET_NAME The tab name, Sheet1 if unchanged No
SYNC_SECRET A long random string you generate Yes
Variable → Value → Secret?
WEBFLOW_API_TOKEN
The read-only site token
Yes
WEBFLOW_COLLECTION_ID
The collection ID from the collection's settings
No
SHEET_FIELDS
Comma-separated field slugs, for example name,slug,post-summary
No
GOOGLE_CLIENT_EMAIL
client_email from the service account JSON
No
GOOGLE_PRIVATE_KEY
private_key from the service account JSON, including the BEGIN and END lines
Yes
GOOGLE_SPREADSHEET_ID
The ID from the spreadsheet URL
No
GOOGLE_SHEET_NAME
The tab name, Sheet1 if unchanged
No
SYNC_SECRET
A long random string you generate
Yes

This route needs no NEXT_PUBLIC_BASE_PATH, because nothing in the browser calls it. Webflow Cloud does not create that variable, so if you later add a page that calls the route client-side, add it yourself with the mount path as its value.

The table also doubles as the hand-off document on a client project: it says exactly which credentials exist and who issues each one.

Push the repository to GitHub, then connect it to Webflow Cloud and create an environment for your main branch, following Webflow's bring-your-own-app docs. Choose a mount path such as /sync; the app will live under that path on your site, so the route's public URL becomes https://your-domain.com/sync/api/sync.

Without a custom domain, use the environment's own URL, with the same mount path. Add the variables, trigger the first deploy, and watch the build log. A green build and an environment URL that responds 401 to an unauthenticated POST show that the route is live and rejecting unauthenticated calls.

6. Trigger the sync and verify the sheet

Call the deployed route once with the real secret and open the spreadsheet. The route is a plain HTTP endpoint, so anything that can send a POST with a header can drive it, from a terminal to a scheduler you already run elsewhere. Each successful run rewrites the tab from the top, so a second run replaces the first run's output.

Replace the domain, mount path and secret and run:

curl -s -X POST https://your-domain.com/sync/api/sync \
  -H "x-sync-secret: YOUR_SYNC_SECRET"

The response is a short JSON object where synced is the number of items returned for the collection and columns is the header count (four fixed columns plus your field slugs).

Switch to the sheet: row 1 holds the headers id, lastPublished, isDraft, isArchived and your field names, and each following row is one collection item. Filter isDraft and isArchived to false to see only the items a visitor could reach.

If you later remove a slug from SHEET_FIELDS, clear the tab once by hand, because the route clears leftover rows but not leftover columns. Keep SYNC_SECRET in server-side code; a browser button needs its own server-side check, such as a session.

Each successful call now leaves the sheet matching the latest collection data returned to the route, with draft status and last-published timestamps beside your chosen fields.

What causes a Webflow CMS to Google Sheets sync to fail?

I keep the sync reliable by checking four recurring Webflow Cloud failure categories: an edge runtime directive, a URL missing the mount path, a key read from disk, and a secret check that differs between next dev and Workers.

I trace each entry from the symptom you observe to the Webflow Cloud runtime detail behind it.

The Webflow Cloud build fails on the sync route, but next build passes locally

Cause: Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, and that adapter does not support the Next.js edge runtime target. The word "edge" means two different things here. Webflow Cloud runs on Cloudflare Workers, which is an edge platform, and the docs describe the deployment that way.

The Next.js edge runtime is a separate compile target that export const runtime = 'edge' opts a route into, and OpenNext cannot build it. The bring-your-own-app docs page still tells you to add that directive to API routes, so a developer following the docs faithfully can produce a build the adapter cannot complete.

Fix: Delete the directive from app/api/sync/route.ts and redeploy. The route already runs on the Workers platform without it; there is no replacement line to add.

If you later add a middleware.ts to the project, keep that filename: the framework customization docs state that Node.js runtime middleware isn't supported and only Edge runtime middleware works on the Workers runtime, and the Next.js 16 proxy.ts rename runs on the Node runtime, so a renamed file cannot run at all.

The route 404s on the live site but responds under next dev

Cause: The live route responds at a public URL that includes the app's mount path, while the local URL does not. Locally, next dev serves the handler at http://localhost:3000/api/sync. On Webflow Cloud, with a mount path of /sync, the same handler answers at https://your-domain.com/sync/api/sync.

A curl command or scheduler job copied from local testing points at /api/sync on the live domain, which misses the mount path and returns a 404. The mount path is injected at build time and is not something you configure in next.config, which is why nothing in the repository hints that the path exists.

Fix: Prefix every external caller's URL with the mount path shown in the environment settings. For a page in the app that calls the route from the browser, add a NEXT_PUBLIC_BASE_PATH variable set to the mount path and build the URL as ${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/sync.

This is because client-side fetch calls must manually include the base path.

A quick check: if the environment URL root loads your app's home page, append /api/sync to that exact URL and POST to it.

The deployed sync route returns 500 while next dev runs the same code

Cause: The deployed route returns 500 when Google service account code reads the key file from disk with fs.readFileSync, because that pattern cannot run here. The deployed route executes on Cloudflare Workers, where node:fs is unavailable at the compatibility date Webflow Cloud pins.

It is a semantic runtime limit, so nothing in the editor flags it: the import type-checks and local next dev runs on Node, so the problem can surface only once the code runs on Webflow Cloud. node:path, by contrast, is available, which is why a project can import one Node built-in without trouble and break on the next.

Fix: Remove the fs.readFileSync call, which cannot run on Workers. Drop any JSON import of the key file too, because a key bundled into the repo is an exposed credential.

Put client_email and private_key into the GOOGLE_CLIENT_EMAIL and GOOGLE_PRIVATE_KEY environment variables, with the key marked as a secret, and let getGoogleAccessToken read them from process.env in app/api/sync/route.ts.

If crypto.subtle.importKey then throws, the most likely cause is a pasted key with literal \n sequences instead of line breaks; the .replace(/\\n/g, "\n") call handles the common case, and re-pasting the key with its real newlines handles the rest.

Remove the JSON file from the repository history as well, since a committed key stays in git even after the file is gone.

The secret check fails under next dev after switching to timingSafeEqual

Cause: Switching to crypto.subtle.timingSafeEqual creates an environment mismatch because Workers exposes it as an extension on crypto.subtle, while the method does not exist under local next dev.

A route that calls crypto.subtle.timingSafeEqual passes in production and throws on your machine. The two environments disagree on a function that looks like a standard. The hand-written constantTimeEqual in the route sidesteps it, but a later refactor toward the helper reintroduces the split.

Fix: Keep the comparison in plain JavaScript, as in constantTimeEqual, which runs in both places. After checking that lengths match, the function XORs every character instead of exiting on the first difference.

If you must use a runtime-specific API anywhere else in the route, guard it with a feature check (typeof fn === "function") and provide the fallback, and test both next dev and a deployed preview before calling the change done.

What you can build next with Google Sheets and Webflow

Once the collection lands in the sheet on demand, people who asked for it stop asking for exports and filter and pivot on their own, without anyone touching the CMS. A second collection becomes a second environment variable and a second tab. For a sync that runs the other way, into a database you own, the external database guide uses the same paging loop.

For anything past a one-way mirror, Webflow's developer docs cover the CMS endpoints a write route needs and the Webflow Cloud limits that apply to it.

Frequently asked questions

Can the sync run on a schedule from inside Webflow Cloud?

Not from inside Webflow Cloud, which has no built-in scheduler; the route runs only when called. Any external scheduler, such as a cron job or a CI workflow, can call it, sending the x-sync-secret header to the full mount-path URL. Each run rereads the whole collection and must finish inside Webflow Cloud's 20-second request timeout, so time a run on your largest collection before scheduling it often.

Why does Google return 403 when the token exchange succeeded?

A 403 after a successful token exchange often means the service account cannot edit the spreadsheet; token exchange does not confirm spreadsheet access. Open the sheet, click Share, and confirm the service account's client_email is listed with the Editor role. Also check that the Google Sheets API is enabled in the same Cloud project that issued the key.

Can I push edits from the sheet back into the Webflow CMS?

A separate route can read rows from the sheet through the Sheets API and send them to the Webflow REST API using a token with the required CMS permissions. Define how the route handles item status, and keep the item id column as the key so it can associate rows with the corresponding items.

Which Webflow plan do I need for this sync?

Any site plan from the free Starter tier up includes Webflow Cloud, so the route deploys without a paid plan. Mounting the app under your own custom domain requires Premium or higher.

Do draft items show up in the sheet, and can I exclude them?

Yes, drafts appear because the route copies every item the endpoint returns along with its isDraft value, and archived items come back too. To keep them out, add .filter((item) => !item.isDraft && !item.isArchived) to the items array before the route builds rows. Keeping drafts in the sheet suits editorial planning views.


Last Updated
October 9, 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.