How to publish Notion or Google Docs content to the Webflow CMS with CloudPress and Webflow Cloud

Learn how to publish Notion or Google Docs posts to the Webflow CMS with CloudPress.

How to publish Notion or Google Docs content to the Webflow CMS with CloudPress and Webflow Cloud

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

Moving posts into the Webflow CMS is the easy half. Knowing an import actually changed what the CMS returns is the half people skip.

In a typical content team, marketing drafts in Notion, the long-form pieces get written and reviewed in Google Docs, and the site is Webflow. Copying a post across by hand means rebuilding the headings and re-uploading the images. Then you check every link again, and the friction is enough that you defer the copy.

With this build, CloudPress writes the source content into the Webflow CMS as collection items. The source structure maps onto the fields you define on the collection.

A CloudPress connection plus a small Webflow Cloud audit app reads the collection-items API response and returns a fingerprint of its contents. Without it, confirming an import means opening the collection in the Designer and reading each item's rich text against the source.

The audit app is a Next.js project mounted on the same site. It reads the collection through Webflow's CMS REST API with server-side authentication settings and protects the audit route behind a shared secret. Run an export, refresh, and see whether the fingerprint changed.

What do you need to publish Notion or Google Docs content with CloudPress in Webflow?

You need four prerequisites: a Webflow site, CloudPress source access, CMS API authentication details, and a compatible Next.js project. Webflow Cloud is available from the free Starter site plan up, while mounting an app to a custom domain requires Premium or higher.

Prepare the site, source connection, API access, and application environment before configuring the content pipeline.

Have the following prerequisites ready:

  • A Webflow site with the CMS, and a plan that fits the archive: Webflow Cloud itself runs from the free Starter plan up, and mounting an app to a custom domain requires Premium or higher.
  • CloudPress account with source access: CloudPress exports from Google Docs, Google Sheets and Notion. Connect the workspace you write in, and authorize it against the Webflow site.
  • A Webflow Site API token withCMS:read: Generate it in Site settings > Apps & integrations > API access. The CMS API authenticates with Authorization: Bearer <token>, and listing collection items requires the CMS:read scope. Store the token only as a secret in Webflow Cloud, never in the browser.
  • Next.js 15 or higher, built with npm: The bring-your-own-app page lists Next.js 15 as the floor and Node.js 22 or later.

With these pieces ready, the build hinges on mapping one source post correctly before deploying the audit app.

6 steps to publish CloudPress exports to the Webflow CMS in Webflow Cloud

Publishing and verifying CloudPress exports requires a destination collection, a tested field mapping, and a mounted Webflow Cloud app that reads and fingerprints the resulting CMS response.

Build the content path first, then add the authenticated route and audit page that verify what Webflow returns.

1. Create the destination collection in the Webflow CMS

Create a new collection with a name that matches its contents, such as "Blog Posts". In the Designer, add it from the CMS panel. The destination collection's field list is the contract each CloudPress export has to satisfy.

The Webflow CMS stores structured content in defined collections, and CloudPress maps source fields onto collection fields, so create it before you connect anything.

I keep the field set small on the first pass. A name, a slug, a rich text body, a main image, and a publish date are enough to prove the pipeline; a mapping mistake is easy to spot across the initial field set and harder to spot after you expand it. Add author, category, and SEO fields once one post has arrived intact.

Confirm the destination field types against CloudPress's mapping requirements. While you are in the collection settings, copy its collection ID: the audit app reads items from https://api.webflow.com/v2/collections/{collection_id}/items, so that ID goes into an environment variable later.

You now have an empty collection whose fields describe exactly what a post looks like on this site, plus the API details the audit app will use.

2. Connect your Notion workspace or Google Docs to Webflow in CloudPress

Authorize CloudPress against both ends, then pair the fields. The body-to-rich-text pairing determines whether the post structure reaches Webflow intact. CloudPress needs access to the Notion workspace or Google Docs holding the content, and authorization against the Webflow site that holds the Blog Posts collection.

Control labels differ by source type, so follow CloudPress's setup documentation for the source you are connecting.

Pair the post title with the Name field, the body with the rich text field, the featured image with the image field, and the publish date with the date field. Define slug handling according to CloudPress's documentation and your publishing workflow, deciding deliberately whether the slug comes from the source or Webflow generates it.

Review permissions on the Webflow side too. CloudPress writes with whatever access you granted it. On a client site, use an account the client controls so the authorization remains available after the engagement.

When the mapping saves, CloudPress knows which source field lands in which Webflow field, and nothing has been written yet.

3. Run a single test export from CloudPress

Choose a test post with a heading hierarchy, at least one image, a bulleted list, and a link, because those four are where formatting gets lost. Export that one post, then move to Webflow.

Open the CMS, open the collection, and find the item by name. Read the rich text body against the source: headings at the right level, list items still list items. Confirm the link still points where it should and the image field contains the imported image.

Anything wrong here is a mapping problem, and it is far cheaper to fix on one item than after a bulk export. Adjust the mapping in CloudPress, delete the test item in Webflow, and export again until the item reads correctly.

One post from your source now lives in the Webflow collection with its structure intact, which is the baseline the audit app will read back.

4. Scaffold the Next.js app for Webflow Cloud

Create a plain Next.js project and change almost nothing. Webflow Cloud expects the default configuration that create-next-app produces. Its configuration page is explicit: "You don't need to add an adapter, a base path, or an output mode."

You now have a project that Webflow Cloud can build without a config change.

Two things to leave out. Do not add a runtime export to any route file. And don't set basePath or assetPrefix yourself: Webflow Cloud derives both from the environment's mount path at build time and overwrites whatever you commit, so the documented approach is to read the mount path at runtime rather than hard-code it.

Deploy the project to Webflow Cloud and set a mount path such as /content-audit. The app appears at that path on your domain. The project is now ready for its first Webflow Cloud build at the configured mount path.

5. Add the authenticated audit Route Handler

Create one GET handler that calls the CMS API with a bearer token, then returns the items with a SHA-256 fingerprint of the response body. A shared-secret check is the security boundary on the route itself. The collection ID lives in an environment variable so the same app can point at a different collection without a code change.

Create the file at app/api/audit/route.ts with this content:

import { NextResponse } from 'next/server';

type TimingSafeEqual = (a: ArrayBufferView | ArrayBuffer, b: ArrayBufferView | ArrayBuffer) => boolean;

function findTimingSafeEqual(): TimingSafeEqual | null {
  const hosts: Record<string, unknown>[] = [
    crypto as unknown as Record<string, unknown>,
    crypto.subtle as unknown as Record<string, unknown>,
  ];
  for (const host of hosts) {
    const fn = host?.timingSafeEqual;
    if (typeof fn === 'function') return (fn as TimingSafeEqual).bind(host);
  }
  return null;
}

async function secretMatches(header: string | null, secret: string): Promise<boolean> {
  if (!header) return false;
  const enc = new TextEncoder();
  const a = enc.encode(header);
  const b = enc.encode(secret);
  if (a.byteLength !== b.byteLength) return false;

  const timingSafeEqual = findTimingSafeEqual();
  if (timingSafeEqual) return timingSafeEqual(a, b);

  // Use a full byte comparison during local next dev.
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

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

export async function GET(request: Request) {
  const secret = process.env.AUDIT_SECRET;
  const token = process.env.WEBFLOW_API_TOKEN;
  const collectionId = process.env.WEBFLOW_COLLECTION_ID;

  if (!secret || !token || !collectionId) {
    return NextResponse.json({ error: 'Missing environment variables' }, { status: 500 });
  }

  const ok = await secretMatches(request.headers.get('x-audit-secret'), secret);
  if (!ok) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // The list endpoint returns at most 100 items per request, so page
  // through it. A single unpaginated call fingerprints only page one,
  // and an export that adds item 101 would leave the hash unchanged.
  const pages: string[] = [];
  let offset = 0;
  let total = Infinity;

  while (offset < total) {
    const url =
      `https://api.webflow.com/v2/collections/${collectionId}/items` +
      `?limit=100&offset=${offset}`;

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

    if (!upstream.ok) {
      return NextResponse.json(
        { error: `Webflow responded ${upstream.status}` },
        { status: 502 },
      );
    }

    const raw = await upstream.text();
    pages.push(raw);

    let parsed: { pagination?: { total?: number } };
    try {
      parsed = JSON.parse(raw);
    } catch {
      // A 200 carrying non-JSON (a proxy error page, a truncated body)
      // would otherwise throw an opaque 500 from inside the handler.
      return NextResponse.json({ error: 'Upstream returned non-JSON' }, { status: 502 });
    }

    total = parsed.pagination?.total ?? 0;
    offset += 100;
  }

  const combined = pages.join('');
  const rawBytes = new TextEncoder().encode(combined);
  const fingerprint = await sha256Hex(rawBytes);

  return NextResponse.json({
    fetchedAt: new Date().toISOString(),
    bytes: rawBytes.byteLength,
    pages: pages.length,
    fingerprint,
  });
}

That file gives you a route that refuses requests without the right header, authenticates to the CMS API with a bearer token, walks every page of the collection, and returns a hash that changes whenever the combined response changes.

The probe around timingSafeEqual is deliberate. Cloudflare exposes timingSafeEqual on crypto.subtle as a non-standard extension to the Web Crypto API, and the global crypto under next dev has no such method.

Node ships a timing-safe compare, but it lives in node:crypto, which Webflow's compatibility guidance steers you away from in favor of SubtleCrypto. Looking the method up at call time keeps one file working in both places without importing a Node module.

Why pagination and payload fingerprinting matter for CMS exports

Returning a 502 signals a failed upstream response, and the bytes and pages fields are quick sanity checks: an export that adds a long post should increase the byte count, and crossing 100 items should increase the page count.

The hash covers every page joined together, so any change in any page moves the fingerprint. The pagination loop isn't optional. The list endpoint caps at 100 items per request and reports the real count in pagination.total, so a single call fingerprints only the first hundred.

Without the loop, a collection of 150 posts would report an unchanged hash no matter what arrived in the second hundred, which is exactly what a content archive runs into first.

Add a rate-limiting control supported by the deployment before exposing the audit route beyond trusted operators. The shared secret blocks unauthenticated requests, while the rate limit prevents repeated guesses from consuming the CMS API quota.

Run npm run dev, set AUDIT_SECRET, WEBFLOW_API_TOKEN and WEBFLOW_COLLECTION_ID in a local .env.local, and request http://localhost:3000/api/audit with the header set.

You should get JSON back containing the imported test post and a hexadecimal fingerprint.

6. Add the audit page and deploy it with its variables set

Build a page that calls the route through the mount path, then set the variables in Webflow Cloud and deploy. A client fetch that omits the mount path requests the wrong deployed URL. Webflow Cloud's bring-your-own-app page states that "Client-side fetch calls must manually include the base path," so the page prefixes the request with NEXT_PUBLIC_BASE_PATH.

The page that holds the secret field and prints the response lives at app/audit/page.tsx:

'use client';

import { useState } from 'react';

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

export default function AuditPage() {
  const [secret, setSecret] = useState('');
  const [output, setOutput] = useState('');
  const [status, setStatus] = useState<number | null>(null);

  const [busy, setBusy] = useState(false);

  async function runAudit() {
    setBusy(true);
    try {
      const res = await fetch(`${basePath}/api/audit`, {
        headers: { 'x-audit-secret': secret },
      });
      setStatus(res.status);
      setOutput(JSON.stringify(await res.json(), null, 2));
    } catch {
      // Without this a network failure leaves the page silent.
      setStatus(null);
      setOutput('Request failed. Check the network tab.');
    } finally {
      setBusy(false);
    }
  }

  return (
    <main style={{ padding: 24, fontFamily: 'monospace' }}>
      <h1>Collection audit</h1>
      <input
        type="password"
        placeholder="Audit secret"
        value={secret}
        onChange={(e) => setSecret(e.target.value)}
      />
      <button onClick={runAudit} disabled={busy} style={{ marginLeft: 8 }}>
        {busy ? 'Reading' : 'Read collection'}
      </button>
      {status !== null && <p>HTTP {status}</p>}
      <pre>{output}</pre>
    </main>
  );
}

The page now sends the secret as a header and prints whatever the route returns, including the fingerprint and the returned item payload.

Webflow Cloud makes both secret and non-secret variables available during the build and at runtime, and it redacts secrets from build logs. That is what lets NEXT_PUBLIC_BASE_PATH work, since the value must be present when the build runs.

Setting environment variables

Set these four in the environment settings in Webflow Cloud:

Variable Secret Value
WEBFLOW_API_TOKEN Yes A Webflow Site API token with the CMS:read scope
AUDIT_SECRET Yes A long random string; whoever runs audits holds it
WEBFLOW_COLLECTION_ID No The destination collection's ID, from its settings in the Designer
NEXT_PUBLIC_BASE_PATH No The mount path, for example /content-audit; leave it unset locally
Variable → Secret → Value
WEBFLOW_API_TOKEN
Yes
A Webflow Site API token with the CMS:read scope
AUDIT_SECRET
Yes
A long random string; whoever runs audits holds it
WEBFLOW_COLLECTION_ID
No
The destination collection's ID, from its settings in the Designer
NEXT_PUBLIC_BASE_PATH
No
The mount path, for example /content-audit; leave it unset locally

With those saved, the next build can read the settings it needs while the API token stays in a secret variable, redacted from build logs and never inlined into the browser bundle.

Deploy the updated app, then let Webflow Cloud build. Open https://yourdomain.com/content-audit/audit, paste the audit secret, and click the button. You should see HTTP 200, the fingerprint, and the test post's fields.

Run a second export from CloudPress and click again. If the export changes the response, the fingerprint should differ, and the byte count should grow; if both are unchanged, the export didn't change this response, whatever CloudPress reported.

What causes CloudPress publishing to Webflow Cloud to fail?

CloudPress publishing and its Webflow Cloud audit can fail because of incompatible runtime configuration, a missing mount-path prefix, environment-specific cryptography support, or an unavailable Node filesystem module.

Match the visible symptom to the relevant cause and fix before changing the CloudPress field mapping or repeating the export.

The Webflow Cloud build reports an OpenNext runtime error

Cause: A Webflow Cloud build can report an OpenNext runtime error after a route or page adds runtime = 'edge'. Webflow Cloud runs on Cloudflare Workers, and its docs describe deployments as running on the Edge runtime.

The Next.js runtime = 'edge' export is a separate framework feature that changes how Next.js compiles the route. Adding it overrides the plain Next.js configuration that Webflow Cloud expects rather than selecting the deployment environment.

Fix: Return to the default configuration produced by create-next-app. Remove the runtime export from the affected route or page, and do not add an adapter, base path, or output mode. Redeploy the project through Webflow Cloud after saving the change.

The app should build with Webflow Cloud's injected configuration and remain available at its configured mount path.

The audit page returns 404 for /api/audit on the deployed site but works under next dev

Cause: Open the Network tab and inspect the failing request's URL. A root-level /api/audit request confirms that the deployed page lacks the mount-path prefix Webflow Cloud requires. I have shipped this bug and caught it only after deployment because the local run made the bare path look correct.

Locally, the app runs at the domain root, while the deployed app is mounted at a path like /content-audit.

Fix: Set NEXT_PUBLIC_BASE_PATH to the deployed mount path in the Webflow Cloud environment settings, then trigger a new build. A deployment built before you saved the variable still carries an empty string because the build requires a value.

If the mount path ever changes, update the variable and rebuild. The client should then request the mounted URL instead of the root-level route.

The audit route throws on the secret comparison under next dev

Cause: Under next dev, the secret check throws when the route calls crypto.subtle.timingSafeEqual directly, because that method is a Cloudflare extension rather than part of Web Crypto.

Linting and type-checking will not catch it, so exercise the route locally rather than trusting the build. Reaching for node:crypto instead trades local failure for deployment failure.

Fix: Retain the lookup and full-byte fallback used by secretMatches. The route checks for timingSafeEqual at call time and compares every byte locally when the method is unavailable.

Test the authenticated request in both local development and the deployed app before trusting it. An import from node:crypto trades local failure for production failure, so keep the guard.

The Webflow Cloud build fails when a route imports node:fs

Cause: There is no filesystem to talk to. Webflow's Node.js compatibility page gives that as the reason directly, listing fs under what to avoid with the note "No file system access; use external storage."

Be careful about the path half of the folklore here: the same page calls path natively supported in its prose and lists it in the avoid column, so it says both things and is not a safe thing to build on. Writing an audit log to disk is the usual trigger because the route tries to use a filesystem the deployment doesn't provide.

Fix: Remove the node:fs import and stop writing to the filesystem. The audit route already returns the receipt to the caller, so the client has the record. If you need persistence, POST the fingerprint and timestamp to an external log store from the same handler instead.

Any code that needs the filesystem belongs outside Webflow Cloud. After removing the dependency, rebuild and confirm that the audit response still returns its timestamp, byte count, fingerprint, and items.

What you can build next with CloudPress and Webflow

If Notion is your source and you would rather own the pipeline than pay for one, our guide to syncing a Notion database builds the same job directly against Notion's API, covering pagination, property mapping and repeatable writes.

The trade-off is the usual one: more control and no per-seat cost, in exchange for code you then maintain.

For connection options, see the Webflow and Notion integration. For deeper customization beyond what CloudPress handles natively, explore the CMS REST API and the Webflow Cloud runtime in the Webflow developer docs.

Frequently asked questions

What happens if my repository has a pnpm-lock.yaml?

The build can succeed on your machine and fail on Webflow Cloud, whose docs state: "Currently, Webflow Cloud supports only the npm package manager." Before connecting the repository, delete pnpm-lock.yaml or yarn.lock, run npm install, and commit the resulting package-lock.json. Webflow Cloud reads that npm lockfile when it builds the connected repository.

Can I protect the audit page itself with middleware?

Yes. Use middleware.ts because Webflow Cloud supports Edge runtime middleware on its Workers runtime, not Node.js runtime middleware. Newer Next.js versions rename middleware to proxy, which runs on the Node runtime, so keep the file named middleware.ts. It can guard the audit page before a request reaches the authenticated Route Handler while preserving deployment compatibility.

Could I use MD5 for the fingerprint?

Yes, on Workers: crypto.subtle.digest('MD5', ...) is a non-standard Cloudflare extension. Cloudflare warns that MD5 is weak and should not be used for security. You can use it to track content changes for this fingerprint, but SHA-256 runs in both local and deployed environments. That portability is why the audit route uses SHA-256 instead.

Will a large content archive hit Webflow Cloud's request or CPU limits?

Yes, depending on your plan history. When CMS and Business merged into Premium in May 2026, ex-CMS sites gained monthly limits (1M to 2M web app requests, 15s to 30s CPU) while ex-Business sites saw reductions (10M to 2M requests, 120s to 30s CPU). These plan details appear in Webflow's help article. In contrast, Webflow Cloud's limits page specifies per-request ceilings: a 20s timeout, 30s Worker CPU, and 6 simultaneous outgoing requests—all relevant for auditing large archives.


Last Updated
September 19, 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.