How to move Squarespace blog posts into the Webflow CMS

How to move Squarespace blog posts into the Webflow CMS

Import Squarespace blog posts into the Webflow CMS with a Webflow Cloud app that keeps original slugs and publish dates.

How to move Squarespace blog posts into the Webflow CMS

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

Move a Squarespace blog into the Webflow CMS with a small Webflow Cloud importer that keeps every slug and original publish date.

Squarespace blog posts carry years of inbound links, so a migration has to keep each post's slug and original date. Webflow's CSV import handles simple archives, including HTML bodies, so try it first.

When you need to validate every record and see exactly which slug failed, a small Webflow Cloud app does the job better.

This build is a Next.js app with a key-protected import page and a Route Handler that writes each post into a Blog Posts collection, with your Webflow API token kept on the server. The original publish date goes into its own Date field, because Webflow's system dates record the import day.

What do you need to move Squarespace blog posts into Webflow?

Moving a blog from Squarespace to Webflow requires a clear setup in both platforms and a few local tools to safely process your export.

Here are the five requirements you need:

  • A Premium Site plan: Premium includes 20,000 CMS items and 40 Collections, per Webflow's pricing page, and it covers mounting the app on a custom domain.
  • A Webflow API token: A site token with the CMS:write scope for the target site so that the route can create items.
  • Your Squarespace blog export: The posts you will convert into four-key JSON records before the build.
  • Node.js 22 or later: Webflow's docs set Node.js 22 as the general floor, and npm installs every package in this build.
  • Next.js 15: Pinned when you scaffold, the release line Webflow Cloud supports.

Webflow Cloud runs on every plan from the free Starter plan up, but the free plan allows only 50 CMS items, and Basic has no CMS, so any archive needs Premium.

7 steps to move Squarespace blog posts into Webflow Cloud

The build is a Webflow CMS collection, a key-protected Route Handler and an import page. Posts travel in the request body because the route runs on Cloudflare Workers with no file system, so the steps start in the CMS.

1. Create the Blog Posts collection in the Webflow CMS

Create a collection that has a slot for every piece of a Squarespace post you want to keep. In the Designer, open the CMS panel and create a new collection called Blog Posts, with one field each for the title, the URL slug, the HTML body as Rich Text, and the original publish date as a Date/Time field.

Webflow's own created and published dates are system fields you cannot set, which is why the original date gets its own field.

The route's FIELD_MAP maps title to 'name' and slug to 'slug', the two fields every new collection already has, so add only the body and date fields. Keeping the date in its own field also lets your blog template sort and display it like any other field.

The slug keeps an inbound link working only if the collection's URL path matches the path Squarespace used. A collection named Blog Posts defaults to /blog-posts/, so set its URL to blog to match Squarespace's /blog/<slug>.

If the prefix still changes, add 301 redirects under Site settings > Publishing, where a two-column CSV imports them in bulk; Webflow's redirects guide notes that a new import replaces every existing redirect, so export them first.

Open each field's settings and write down its slug exactly as Webflow displays it. The import route's FIELD_MAP uses those slugs. You should now have an empty Blog Posts collection with four fields and a list of four slugs.

2. Export the Squarespace posts and convert them to JSON

In Squarespace, open Settings > Import & export content and export to WordPress format, which downloads an .xml file. Squarespace's export guide notes that only one blog page exports, and drafts do not.

The importer accepts a plain JSON array of flat records with four keys, so reshape each post from the export to match posts.json.

Work through the export file in this order:

  1. Open the export file.
  2. For each post, copy the title into "title".
  3. Copy the last segment of the post's current URL into "slug", so the Webflow item keeps the same ending.
  4. Paste the post body HTML into "bodyHtml".
  5. Write the publish date as an ISO 8601 string in "publishedAt".

For an archive of more than a few dozen posts, use a spreadsheet. Put one row per post in four columns named exactly title, slug, bodyHtml and publishedAt, then export the sheet and convert it to a JSON array. A text editor is enough for a few dozen posts or fewer.

Before converting the whole archive, I run the one-post test from step 6 with the sample below.

JSON post format sample

Save the converted posts as posts.json in this shape:

[
  {
    "title": "Spring menu notes",
    "slug": "spring-menu-notes",
    "bodyHtml": "<p>Our spring menu starts this week.</p>",
    "publishedAt": "2024-03-18T09:00:00.000Z"
  }
]

That file is the only input the importer reads. Check that it opens in any JSON validator without errors.

3. Scaffold the Next.js app with npm

Create a new app with the App Router and TypeScript, pinned to Next.js 15, then add a shared type for the post records. Install with npm, and use npm for anything you add later, such as an HTML sanitizer for post bodies.

Run these commands from the folder where you keep projects:

npx create-next-app@15 squarespace-import --ts --app --eslint --no-tailwind --no-src-dir --use-npm --import-alias "@/*"
cd squarespace-import
mkdir lib

You now have an empty App Router project with a lib folder.

Define the post type

The import route validates every record against four keys, so define that shape once in lib/post.ts:

// lib/post.ts
export type ImportPost = {
  title: string;
  slug: string;
  bodyHtml: string;
  publishedAt: string; // ISO 8601, for example 2024-03-18T09:00:00.000Z
};

Start the dev server with npm run dev. The default Next.js page should load at http://localhost:3000, and lib/post.ts should compile without type errors.

4. Add the token, endpoint and secrets as environment variables

Store the four values the importer reads as environment variables, never in code. Webflow's environment variables docs state that "Both secret and non-secret environment variables are available to your application's build process and to the deployed application at runtime." Secret values are redacted from build logs.

Set these variables in .env.local for local runs, and in your Webflow Cloud environment for the deployed app:

Variable Secret What it holds
WEBFLOW_API_TOKEN Yes The site API token that can create CMS items
WEBFLOW_COLLECTION_ID No The ID of your Blog Posts collection, from its settings
IMPORT_SECRET Yes A long random string the import page sends as a header
NEXT_PUBLIC_BASE_PATH No The path the app is mounted at, such as /import; leave it empty locally
Variable → Secret → What it holds
WEBFLOW_API_TOKEN
Yes
The site API token that can create CMS items
WEBFLOW_COLLECTION_ID
No
The ID of your Blog Posts collection, from its settings
IMPORT_SECRET
Yes
A long random string the import page sends as a header
NEXT_PUBLIC_BASE_PATH
No
The path the app is mounted at, such as /import; leave it empty locally

Keeping the collection ID in a variable means pointing the importer at a staging collection first is a one-line change. Restart npm run dev after editing .env.local.

5. Write the import Route Handler

Write a POST Route Handler that rejects requests without the import key, then validates the posts and creates one CMS item for each. Leave out export const runtime = 'edge'. Webflow's bring-your-own-app page still tells you to add it, and it breaks the build.

The word "edge" means two things there. Webflow Cloud runs on Cloudflare Workers, an edge platform. The Next.js edge runtime target is separate, and the OpenNext Cloudflare adapter that deploys your app does not support it.

The handler in app/api/import/route.ts checks the key, validates the array, then writes one item per post:

// app/api/import/route.ts
import { NextResponse } from 'next/server';
import type { ImportPost } from '../../../lib/post';

// Field slugs come from your Blog Posts collection settings.
const FIELD_MAP = {
  title: 'name',
  slug: 'slug',
  bodyHtml: 'post-body',
  publishedAt: 'published-on',
} as const;

const KEYS = ['title', 'slug', 'bodyHtml', 'publishedAt'] as const;
const MAX_POSTS = 50;

type Result = { slug: string; status: number; savedSlug?: string; detail?: string };

function isImportPost(value: unknown): value is ImportPost {
  if (typeof value !== 'object' || value === null) return false;
  const record = value as Record<string, unknown>;
  return KEYS.every((key) => typeof record[key] === 'string' && (record[key] as string).length > 0);
}

function safeEqual(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;
}

export async function POST(request: Request) {
  const secret = process.env.IMPORT_SECRET;
  if (!secret || !safeEqual(request.headers.get('x-import-key') ?? '', secret)) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const token = process.env.WEBFLOW_API_TOKEN;
  const collectionId = process.env.WEBFLOW_COLLECTION_ID;
  if (!token || !collectionId) {
    return NextResponse.json({ error: 'Missing Webflow configuration' }, { status: 500 });
  }

  let payload: unknown;
  try {
    payload = await request.json();
  } catch {
    return NextResponse.json({ error: 'Body is not valid JSON' }, { status: 400 });
  }

  if (!Array.isArray(payload) || !payload.every(isImportPost)) {
    return NextResponse.json(
      { error: 'Body must be an array of posts with title, slug, bodyHtml and publishedAt' },
      { status: 400 },
    );
  }
  if (payload.length > MAX_POSTS) {
    return NextResponse.json(
      { error: `Send at most ${MAX_POSTS} posts per request` },
      { status: 400 },
    );
  }

  const results: Result[] = [];
  for (const [index, post] of payload.entries()) {
    let response: Response;
    try {
      response = await fetch(`https://api.webflow.com/v2/collections/${collectionId}/items`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          isDraft: true,
          fieldData: {
            [FIELD_MAP.title]: post.title,
            [FIELD_MAP.slug]: post.slug,
            [FIELD_MAP.bodyHtml]: post.bodyHtml,
            [FIELD_MAP.publishedAt]: post.publishedAt,
          },
        }),
        signal: AbortSignal.timeout(8000),
      });
    } catch (error) {
      results.push({ slug: post.slug, status: 0, detail: String(error) });
      continue;
    }

    if (response.status === 429) {
      // Rate limited: stop here and report what is left, so the next paste resumes cleanly.
      const remaining = payload.slice(index).map((p) => p.slug);
      return NextResponse.json({ stoppedAt: post.slug, remaining, results }, { status: 429 });
    }

    if (!response.ok) {
      results.push({ slug: post.slug, status: response.status, detail: await response.text() });
      continue;
    }

    const item = (await response.json()) as { fieldData?: { slug?: string } };
    const savedSlug = item.fieldData?.slug;
    results.push({
      slug: post.slug,
      status: response.status,
      ...(savedSlug && savedSlug !== post.slug ? { savedSlug } : {}),
    });
  }

  const imported = results.filter((r) => r.status >= 200 && r.status < 300).length;
  return NextResponse.json({ imported, total: results.length, results });
}

The route uses Webflow's create item endpoint with Bearer auth and values wrapped in fieldData, and creates every item as a draft so you can review before publishing. It sends one item at a time, so each failure is tied to a specific slug and its error text.

Webflow can change a slug that already exists in the collection, so the route compares the slug it gets back with the one you sent and reports any difference as savedSlug.

A paste is capped at 50 posts, which keeps each request inside Webflow Cloud's 20-second limit. If Webflow's rate limit returns 429, the route stops and returns the slugs it did not reach, so the next paste resumes cleanly.

The page itself has no access guard, so delete IMPORT_SECRET and the app when the import finishes.

6. Build the import page with the base path

Replace the default home page with a small client page that sends your JSON to the route. The page has to prefix its fetch with the mount path, because Webflow's bring-your-own-app page states that "Client-side fetch calls must manually include the base path." Webflow Cloud does not create NEXT_PUBLIC_BASE_PATH, so set it yourself.

The client page needs only a key input and a textarea, with the raw response printed below; it replaces app/page.tsx:

// app/page.tsx
'use client';

import { useState } from 'react';

export default function ImportPage() {
  const [json, setJson] = useState('');
  const [key, setKey] = useState('');
  const [result, setResult] = useState('');

  async function runImport() {
    setResult('Importing...');
    const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '';
    try {
      const response = await fetch(`${basePath}/api/import`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'x-import-key': key },
        body: json,
      });
      const text = await response.text();
      setResult(`${response.status}\n${text}`);
    } catch (error) {
      setResult(`Request failed: ${String(error)}`);
    }
  }

  return (
    <main style={{ maxWidth: 720, margin: '2rem auto', fontFamily: 'sans-serif' }}>
      <h1>Squarespace post import</h1>
      <input
        type="password"
        placeholder="Import key"
        value={key}
        onChange={(event) => setKey(event.target.value)}
      />
      <textarea
        rows={16}
        style={{ width: '100%', marginTop: '1rem' }}
        value={json}
        onChange={(event) => setJson(event.target.value)}
      />
      <button onClick={runImport}>Import posts</button>
      <pre>{result}</pre>
    </main>
  );
}

The page shows the raw status and body, so a 404 or a validation error appears on screen instead of failing silently. Test it locally with a single post first, because the local server writes to your real collection. Paste a one-item array, enter your import key and click Import posts.

Check the result for that slug's status, then confirm that the post appears in the Blog Posts collection with the intended field values.

7. Deploy to Webflow Cloud and run the full import

Before deploying, add all four environment variables to the Webflow Cloud environment. Set NEXT_PUBLIC_BASE_PATH to the exact mount path, such as /import. Deploy the app to Webflow Cloud at that path and run the full archive from the deployed page. Redeploy if you change the base path. Mounting on the site's custom domain requires Premium or higher, which the CMS already needs.

Paste the archive in batches of up to 50 posts. Each request has a 20-second limit on Webflow Cloud, and Webflow's Data API allows 60 or 120 requests a minute depending on plan, which the guide to 429 rate limits covers.

When every batch reports success, delete IMPORT_SECRET from the environment and revoke the API token, or remove the app entirely. On an agency build, verify that the importer can no longer write to the client's collection.

Open the Blog Posts collection in the CMS panel. The imported items are drafts, so review them, check any savedSlug entries in the results, and publish when ready. Check the live blog to confirm that every post appears under its original slug and displays its original publish date.

What causes a Squarespace import on Webflow Cloud to fail?

Most failures come from the build, the mount path or Node APIs that Workers does not provide. The rest come from mismatched CMS fields or image URLs left pointing at Squarespace.

The Webflow Cloud build fails with an unsupported runtime error on the import route

Cause: The directive targets the Next.js edge runtime, and the OpenNext Cloudflare adapter that Webflow Cloud uses to deploy Next.js does not support it. It's easy to get confused.

Webflow's docs describe Webflow Cloud as deploying "using the Edge runtime," meaning the Cloudflare Workers platform. The same bring-your-own-app page then tells you to add the directive, so a developer following the docs faithfully ships a broken build.

Fix: Delete the export const runtime = 'edge' line from app/api/import/route.ts and from any other route file, then redeploy. The route already runs on Workers without it. If a build still fails, search the project for runtime =, because a copied snippet in a second route will break the build the same way.

The Import posts button returns 404 on the deployed app but works locally

Cause: The page fetched /api/import from the domain root instead of from the mount path. Locally, the app runs at the root, so the request works. On Webflow Cloud, the app lives under a path such as /import. Client-side fetch calls are not rewritten to include it, so the request goes to a path where no import route exists.

Fix: Confirm NEXT_PUBLIC_BASE_PATH in the Webflow Cloud environment matches the mount path exactly. Redeploy after changing it so the page is rebuilt with the new value. The result panel shows the raw status, so a 401 after the fix means the path is now right and only the key is wrong.

The deploy fails with a module error for node:fs

Cause: Code tried to read posts.json from disk with node:fs. Webflow's Node.js compatibility page lists fs as unsupported, so the module is unavailable on the deployed app, even if the same code worked during local testing.

Fix: Send the posts in the request body, as the import route does, and remove any file system import. For a large archive, split posts.json into several files on your machine and paste them one at a time.

Results show a non-2xx status for some slugs

Cause: Webflow rejected that item. The usual reasons are a FIELD_MAP value that does not match the collection's field slug, a date that is not an ISO 8601 string, or a slug already used by another item. Rich text code blocks import as an empty string, so a post built around code samples can look empty without any error.

Fix: Read the detail text for the failing slug, since the route stores the response body Webflow returned for that item. Compare each FIELD_MAP value against the Blog Posts field settings, then re-import only the failed records by filtering posts.json down to those slugs.

Images in imported posts still load from Squarespace's servers

Cause: The importer copies post text and metadata only. Image URLs inside each post body stay exactly as written, so the imported posts still point at Squarespace's servers.

While the Squarespace site stays live, those images load normally, which makes the problem easy to miss during review. If Squarespace stops serving those files after you cancel, the affected posts will show broken images.

Fix: Before closing the Squarespace site, move those image files to hosting you will keep, then replace the old image URLs in the imported posts. Re-importing would create new items, so update the existing ones in the CMS panel or with the API's update endpoint.

Webflow's CSV import, by contrast, downloads images inside rich text fields to your site, which is one reason to try it first for simple archives.

What you can build next with Squarespace content and Webflow

With the archive moved, your posts are structured content the blog template can sort and filter. The same Route Handler pattern suits content that keeps arriving from another system, and the batch import guide shows a no-code route for recurring imports.

For moving content the other way, out of Webflow, see how to export blog posts.

For updating items after the import, Webflow's developer docs cover bulk updates of up to 100 items per request.

Frequently asked questions

Can I protect the import page with Next.js middleware?

Yes, as long as the file is named middleware.ts. Webflow Cloud's framework docs state that only Edge runtime middleware works on the Workers runtime. Next 16 renames middleware to proxy.ts, but proxy runs on the Node runtime and cannot opt into Edge. Renaming the file therefore produces middleware that never runs on your deployed app.

Should I compare the import key with timingSafeEqual?

You can on the deployed app, but timingSafeEqual exists on the Workers runtime and not under local next dev, so a route that uses it can pass in production and throw during local testing. The route's safeEqual helper compares every character instead and runs the same in both places.

Can I install dependencies with pnpm or Yarn?

No. Webflow's bring-your-own-app docs state: "Currently, Webflow Cloud supports only the npm package manager." Keep package-lock.json and install every later addition with npm install.

Can an AI agent audit the migrated posts?

Yes, through Webflow's MCP server, whose CMS tools let agents in tools like Claude Code or Cursor read collection items, so you can ask for posts with an empty body or a missing date instead of opening each item yourself.


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

Jetzt kostenlos loslegen

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
Get started — it’s free
Demo ansehen
Demo ansehen

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.