How to export Webflow blog posts for a migration

How to export Webflow blog posts for a migration with the CMS API and Webflow Cloud

Learn how to build a Next.js Route Handler on Webflow Cloud that pulls blog posts from the CMS API and returns them as a file download.

How to export Webflow blog posts for a migration with the CMS API and Webflow Cloud

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

An on-demand Webflow Cloud route keeps a migration current through cutover by pulling posts through the CMS API and returning a downloadable export with count and checksum headers for verification.

Migrating a blog away from Webflow is seldom a quick task. Because setting up the destination system often takes several weeks, and new content may still be published during that time, an initial export can leave you with outdated data.

The target platform can take weeks to configure. Meanwhile, the editorial team keeps publishing, so an export taken on day one is stale by cutover, and a migration built on it quietly drops whatever went live in between.

Webflow's default CSV export converts references into text or comma-separated lists, excludes multi-locale content, and requires manual operation. In this API route guide, we’ll solve these limitations by preserving item IDs, locale views, and draft/archived flags while allowing repeatable, on-demand execution during cutover.

What do you need to export Webflow blog posts in Webflow Cloud?

You need to prepare these five prerequisites:

  • Webflow site with a blog collection: Use the site containing the blog collection you want to export.
  • Node.js and npm: Install both locally and keep the package-lock.json that npm generates as standard project practice.
  • Next.js 15 or higher: The framework floors on the bring-your-own-app page also list Astro 6 or 7 and Vite 6.1 or higher.
  • API request details: Use Webflow's API reference to identify the authentication requirements used for the request.
  • Blog collection ID and a CMS token: The collection's ID, from its settings panel in the Designer, and a Site API token with the CMS:read scope, from Site settings > Apps & integrations > API access.

With these prerequisites ready, the build depends on configuring the request correctly and keeping its credentials on the server.

6 steps to export Webflow blog posts in Webflow Cloud

Exporting Webflow blog posts takes six steps: scaffold the app, configure its environment, fetch and flatten CMS items, serialize them, secure the route, and deploy it to Webflow Cloud.

Local checks validate each part of the build before deploying the completed route.

1. Scaffold the Next.js app with npm

Webflow Cloud's docs state, verbatim, "Currently, Webflow Cloud supports only the npm package manager." Pin create-next-app to the supported Next.js 15 major, and use npm for the scaffold and dependency lockfile.

During setup, choose TypeScript and the App Router. Keep the project files at the root by declining the src directory, so the default @/ alias points to the project root, where the lib folder will live.

Run the scaffold and answer its setup prompts with those choices:

npx create-next-app@15 blog-export
cd blog-export
npm run dev

Tailwind and ESLint choices are independent of the export.

The generated package-lock.json records the npm dependency tree for reproducible installs. Keep the generated files unchanged and place the export code in its own folder and one route file.

With the dev server running, the default Next.js page renders at localhost:3000, which confirms the toolchain works before you touch Webflow.

2. Set the environment variables

Locally, the request settings, route secret, and browser-side mount path go in a .env.local file at the project root. On Webflow Cloud, they go in the environment's environment variables settings, with the API headers and the secret marked as secret so they are redacted from build logs.

Webflow's bring-your-own-app page states that "Both secret and non-secret environment variables are available to your application's build process ... and remain available to the deployed application at runtime," so a value set once serves both the build and the running route. Generate a random secret with openssl rand -hex 32.

Configure these four variables:

  • WEBFLOW_API_TOKEN: The Site API token with the CMS:read scope. Mark it as a secret on Webflow Cloud so the build log redacts it.
  • WEBFLOW_COLLECTION_ID: The ID of the blog collection to export.
  • EXPORT_SECRET: A long random string the caller sends in an x-export-secret header. A matching value is required; mismatches return 401 before the route contacts Webflow.
  • NEXT_PUBLIC_BASE_PATH: The mount path of the app on your site. A browser-side download button uses it because client fetch calls are not rewritten with the base path.

With these four values set, the local and deployed environments can run the same route without storing credentials in the repository. Save the local file and restart the dev server; Next reads env files at startup, so a value added mid-session stays undefined until the restart.

On Webflow Cloud, confirm that all four variables appear in the environment and that WEBFLOW_API_TOKEN and EXPORT_SECRET are marked as secret.

Keeping credentials in the environment panel makes the repository safe for an agency handoff: the client can rotate the token in one panel while the code stays unchanged.

3. Fetch and flatten the posts

Keeping the fetcher in a plain module lets you type-check and reuse it without a request in hand. It calls Webflow's list-items endpoint, GET https://api.webflow.com/v2/collections/{collection_id}/items, and it has to page.

That endpoint returns 25 items when you pass no limit and 100 at most, so a single request against a 400-post blog returns 25 posts with a 200 and no error, and a migration built on it silently leaves most of the blog behind.

Pagination and item flattening

The loop below reads pagination.total and keeps requesting 100 at a time until it has every item, waiting out a 429 for as long as the Retry-After header says. It then flattens every post into single-level string keys. Nested objects become dot-path keys, and arrays remain JSON strings.

This preserves any reference field returned as an array and avoids an ambiguous comma string. Those references are item IDs, not names, so export the author and category collections the same way and join on ID at the target. Rich text can also contain Webflow component instances as <wf-component> markup, which other platforms will not render.

Two scope decisions are baked in. The route reads the staged /items endpoint rather than /items/live, so unpublished drafts and archived posts move too, each carrying its isDraft and isArchived flag for you to map to the target's status field. And it reads one locale: per Webflow's localization docs, "If you omit the cmsLocaleId, the API will return items from the primary locale."

On a multilingual site, add a cmsLocaleId query parameter, which accepts a comma-separated list of CMS locale IDs, or the translations stay behind. The route file has no runtime directive because Webflow Cloud's OpenNext Cloudflare adapter cannot build the Next.js edge runtime target.

Implementing lib/export.ts

Save this as lib/export.ts:

// lib/export.ts
export type FlatRow = Record<string, string>;

const PAGE_SIZE = 100; // the endpoint's documented maximum

export async function fetchPosts(): Promise<unknown[]> {
  const collectionId = process.env.WEBFLOW_COLLECTION_ID;
  const token = process.env.WEBFLOW_API_TOKEN;
  if (!collectionId || !token) {
    throw new Error('Set WEBFLOW_COLLECTION_ID and WEBFLOW_API_TOKEN');
  }

  const items: unknown[] = [];
  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) {
      // Wait as long as the documented Retry-After header says, then retry this page.
      const wait = Number(res.headers.get('Retry-After') ?? '5');
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!res.ok) {
      throw new Error(`CMS API returned ${res.status}`);
    }

    const page = (await res.json()) as { items?: unknown[]; pagination?: { total: number } };
    const batch = page.items ?? [];
    items.push(...batch);
    total = page.pagination?.total ?? items.length;
    if (batch.length === 0) break; // never loop forever on an unexpected response
    offset += batch.length;
  }

  return items;
}

export function flatten(value: unknown, prefix = '', out: FlatRow = {}): FlatRow {
  if (value === null || value === undefined) {
    out[prefix] = '';
  } else if (Array.isArray(value)) {
    out[prefix] = JSON.stringify(value);
  } else if (typeof value === 'object') {
    for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
      flatten(child, prefix ? `${prefix}.${key}` : key, out);
    }
  } else {
    out[prefix] = String(value);
  }
  return out;
}

Run npx tsc --noEmit, and it passes cleanly. The flatten function turns any returned item shape into flat rows, so a new field type added to the collection shows up as a new key. The number of items fetchPosts returns is the count the X-Export-Count header reports once the Route Handler exists.

4. Serialize the export with a checksum

RFC 4180-style quoting and CRLF (carriage return plus line feed) line endings make the CSV output compatible with table-based importers. A second pure function computes a SHA-256 hex digest of whatever body the route is about to send.

JSON is the default because its escaping preserves HTML blog bodies through the transfer. CSV stays available behind a query parameter for target platforms whose importer only reads tables. The checksum lets the recipient prove the file they imported matches the file the route produced, byte for byte. SHA-256 uses crypto.subtle.digest from Web Crypto, which Workers provides.

Put the CSV writer and the digest in lib/serialize.ts:

// lib/serialize.ts
import type { FlatRow } from './export';

export function toCsv(rows: FlatRow[]): string {
  const columns = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
  const escape = (cell: string) => `"${cell.replace(/"/g, '""')}"`;
  const lines = [
    columns.map(escape).join(','),
    ...rows.map((row) => columns.map((col) => escape(row[col] ?? '')).join(',')),
  ];
  return lines.join('\r\n');
}

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

Web Crypto is the sole runtime dependency. Type-check again, and the two modules are ready for the Route Handler.

5. Gate the route and wire it together

An export route fronts credentials that can request CMS data, so an unauthenticated version is a liability the moment it deploys, especially on a client's site. The gate compares the x-export-secret header to EXPORT_SECRET with a hand-rolled comparison loop. This keeps the same code path under local next dev and on the deployed Workers runtime.

This migration route does not include a rate limiter, so anyone who obtains the route secret can invoke repeated CMS requests.

Keep the secret out of browser bundles, share it only with the cutover operator, rotate it after use, and remove or turn off the route when the migration is complete. NEXT_PUBLIC_BASE_PATH contains only the public mount path; keep EXPORT_SECRET and CMS request credentials in server-side variables.

Put the comparison in lib/auth.ts so the handler stays short:

// lib/auth.ts
export function secretMatches(header: string | null): boolean {
  const expected = process.env.EXPORT_SECRET ?? '';
  const given = header ?? '';
  if (expected.length === 0 || expected.length !== given.length) {
    return false;
  }
  let diff = 0;
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
  }
  return diff === 0;
}

The Route Handler checks the header, fetches and flattens the posts, serializes the rows, computes the digest, and returns the response.

Implementation: app/api/export/route.ts

Create app/api/export/route.ts to compose the three modules:

// app/api/export/route.ts
import { fetchPosts, flatten, type FlatRow } from '@/lib/export';
import { toCsv, sha256Hex } from '@/lib/serialize';
import { secretMatches } from '@/lib/auth';

export async function GET(request: Request) {
  if (!secretMatches(request.headers.get('x-export-secret'))) {
    return new Response('Unauthorized', { status: 401 });
  }

  const format = new URL(request.url).searchParams.get('format') === 'csv' ? 'csv' : 'json';

  let rows: FlatRow[];
  try {
    rows = (await fetchPosts()).map((item) => flatten(item));
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Export failed';
    return new Response(message, { status: 502 });
  }

  const body = format === 'csv' ? toCsv(rows) : JSON.stringify(rows, null, 2);
  const checksum = await sha256Hex(body);

  return new Response(body, {
    headers: {
      'Content-Type':
        format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8',
      'Content-Disposition': `attachment; filename="blog-posts.${format}"`,
      'X-Export-Sha256': checksum,
      'X-Export-Count': String(rows.length),
    },
  });
}

Successful requests carry the export and its verification headers. Failed requests return a 401 or 502 with a reason.

Start the dev server, then load the secret from .env.local and request the route from a second terminal:

: "${EXPORT_SECRET:?Load EXPORT_SECRET from .env.local}"
curl -i -H "x-export-secret: $EXPORT_SECRET" http://localhost:3000/api/export

That request returns 200 with a JSON body of flat rows. A request that omits the -H flag returns 401 before the CMS API is called. A 502 with a message means the fetcher failed, and the message names which setting or status caused it.

6. Deploy to Webflow Cloud and pull the export

The package-lock.json must be included in the committed project. Set up the Webflow Cloud app and its mount path as the bring-your-own-app page describes, then add WEBFLOW_API_TOKEN, WEBFLOW_COLLECTION_ID, EXPORT_SECRET, and NEXT_PUBLIC_BASE_PATH to the environment.

Leave next.config unchanged for the base path. Webflow Cloud injects the mount path at build time; the bring-your-own-app page puts it as "No adapter, no base path, no wrangler.json."

Once the deployment log shows the build is complete, the route answers at your domain plus the mount path plus /api/export. Load the Webflow Cloud secret into EXPORT_SECRET, and set WEBFLOW_APP_URL to the deployed domain plus the app's mount path.

Pull the export and check it:

: "${EXPORT_SECRET:?Load EXPORT_SECRET from Webflow Cloud}"
: "${WEBFLOW_APP_URL:?Set WEBFLOW_APP_URL to the deployed domain and mount path}"
curl -sS -D export-headers.txt \
  -H "x-export-secret: $EXPORT_SECRET" \
  "${WEBFLOW_APP_URL}/api/export?format=json" \
  -o blog-posts.json
grep -i 'x-export' export-headers.txt
shasum -a 256 blog-posts.json

The grep prints X-Export-Count and X-Export-Sha256, and the shasum output matches the checksum header. On Linux, run sha256sum blog-posts.json instead.

The command saves blog-posts.json on disk. Rerun the same command the morning of the cutover so the file reflects the current collection.

What causes a Webflow blog export to fail?

A Webflow blog export usually fails because of an incompatible Workers runtime target, an incorrect mounted path, unavailable filesystem APIs, or invalid deployed CMS request settings.

The visible status or build error tells you which category to check first.

The Webflow Cloud build fails after deploy even though the next build passes locally

Cause: A local Next.js build can pass before Webflow Cloud's OpenNext Cloudflare adapter rejects the Next.js edge runtime target. The confusion comes from the word "edge" meaning two things on the same docs page.

The bring-your-own-app page says Webflow Cloud "deploys your app using the Edge runtime" and that this setup enables "fast, globally distributed hosting." This describes the platform: Cloudflare Workers, an edge network.

The same page also tells you to add the Next.js edge runtime directive to API routes, which names the Next.js runtime target, a separate thing OpenNext cannot build. A developer following that instruction faithfully ships a broken build.

Fix: Delete the export const runtime = 'edge' line from every route and layout file. The platform already runs the handler on Workers. Redeploy, and the export route compiles.

A client-side fetch to the export route returns 404 on the deployed app but works under next dev

Cause: This applies once you add a front end to the app. The deployed Route Handler uses your domain followed by the app's mount path and /api/export. The bring-your-own-app page states that "Client-side fetch calls must manually include the base path," so a component that calls fetch('/api/export') requests the site root; the request misses the mounted app and returns 404. The call likely works on your machine because next dev serves the app at the root.

Fix: Prefix browser-side calls with the NEXT_PUBLIC_BASE_PATH variable set in the environment. Before shipping a browser trigger at all, weigh what it exposes: any secret used in browser code is visible to whoever loads the page, so a browser trigger only fits an internal tool behind its own login, which supplies the secret after sign-in. For a migration, the curl command with the secret in a shell variable is the safer default.

Prefix the call inside a function the internal tool calls:

export async function downloadExport(secret: string): Promise<Blob> {
  const base = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
  const res = await fetch(`${base}/api/export?format=json`, {
    headers: { 'x-export-secret': secret },
  });
  if (!res.ok) {
    throw new Error(`Export request returned ${res.status}`);
  }
  return res.blob();
}

Call downloadExport after the tool's login step, then hand the returned Blob to a download link.

The build or the first request fails with node:fs not found

Cause: A file write, usually fs.writeFile to drop the export into a temp directory before serving it, causes this failure. On Webflow Cloud the app runs on Workers with a pinned compatibility date, and node:fs requires a newer compatibility date than the one Webflow Cloud pins.

The pinned compatibility date creates apparently inconsistent behavior: node:path resolves on the same runtime.

Fix: Remove the write and return the export directly as the response body, as app/api/export/route.ts does. The Content-Disposition header tells the browser or curl to save it as a file. If you need the export to persist between requests, that storage has to live outside the Worker, in whatever your migration target or object store already provides.

The deployed route returns 502 with "CMS API returned 401" or "CMS API returned 404"

Cause: fetchPosts throws when the CMS API answers with a non-OK status, and the route passes that message through as a 502. In standard HTTP terms, a 401 or 403 typically indicates that WEBFLOW_API_TOKEN is wrong or lacks the CMS:read scope. Missing settings produce the "Set WEBFLOW_COLLECTION_ID and WEBFLOW_API_TOKEN" message.

A 404 typically indicates that WEBFLOW_COLLECTION_ID is wrong or belongs to a different site from the token. Locally, the same code can pass because .env.local holds different values from the Webflow Cloud environment.

Fix: Restore the deployed request settings to the values validated by the local curl command, redeploy, and rerun the deployed command. A successful response returns the export with X-Export-Count and X-Export-Sha256 headers instead of the 502 message.

What you can build next with the Webflow CMS API and Webflow Cloud

A migration runs one direction, but a route that reads the collection on demand is useful long after the cutover: the same pattern feeds a search index or produces a nightly backup someone can diff.

If the goal turns out to be keeping another system current rather than leaving Webflow, the CMS sync pattern is the same fetcher with an upsert on the end.

Frequently asked questions

My Next.js 16 project has proxy.ts instead of middleware.ts. Does that matter for gating the route?

No. The Route Handler gate makes the filename irrelevant for this build. If you add middleware, keep middleware.ts. Webflow Cloud's docs state, "Node.js runtime middleware isn't supported. Only Edge runtime middleware works on the Workers runtime." Next 16's proxy runs on the Node runtime and cannot opt into Edge. This separation keeps authentication inside the handler.

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

You can, with a caveat. MD5 is available through crypto.subtle.digest('MD5', ...) as a Cloudflare Workers extension, though it is non-standard. Cloudflare's own note says "MD5 is considered a weak algorithm. Do not rely upon MD5 for security." It works for an integrity check unrelated to security; SHA-256 avoids the caveat and needs no extension.

Does the export include the images in each post?

No. It includes image URLs, not image files: image fields come back as objects with a url pointing at Webflow's CDN, and rich text embeds the same kind of URL. Webflow's help center is explicit about the risk: "If you delete that site, those assets and links break, including images in rich text fields." Download every asset, inline images included, and rewrite the URLs at the target before you delete the Webflow site or cancel its plan.

Can an agent run the export instead of a person?

Yes. An agent can call the route on a schedule, save each response, and compare X-Export-Count or X-Export-Sha256 between runs before handing the latest file to the migration process. Give it server-side access to the route URL and secret, not browser-bundled credentials, and rotate the secret when the automated migration work ends.


Last Updated
October 2, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com 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.