Migrate a WordPress Blog to Shopify With articleCreate

To migrate a WordPress blog to Shopify, read every post from the WordPress REST API (/wp-json/wp/v2/posts, 100 per page) and create one Shopify article per post with the Admin GraphQL articleCreate mutation. Keep the WordPress slug as the article handle, send date_gmt as publishDate, and rehost images before the old site goes dark.

Shopify’s Store Migration app will not do this for you. Its WooCommerce importer brings products across and leaves blog posts out of scope, so a store with a few hundred posts of SEO history needs a script. The one below is written against Admin GraphQL 2026-10, the current stable version as of October 2026.

What do you need before you start?

Three things. A custom app (or an existing one) on the Shopify store with the write_content or write_online_store_pages scope, which is what both articleCreate and blogCreate require. write_files as well if you plan to rehost body images, and write_online_store_navigation for redirects.

Second, the target blog’s ID. Create it in the admin or with blogCreate, then copy the gid://shopify/Blog/... value. articleCreate will also accept a blog argument and make a new blog inline, but sending that alongside blogId fails with AMBIGUOUS_BLOG, and sending neither fails with BLOG_REFERENCE_REQUIRED. Pick one blog up front.

Third, read access to the WordPress site’s REST API. Public posts need no authentication.

How do you export posts from the WordPress REST API?

GET /wp/v2/posts returns published posts by default. WordPress caps per_page at 100 and tells you how many pages exist in the X-WP-TotalPages response header, so loop until you have read them all.

Add _embed to get the author’s name, the category and tag terms, and the featured image in the same response, instead of making a request per ID. Since WordPress 5.4 you can limit _embed to named link relations, which keeps each page small: _embed=author,wp:term,wp:featuredmedia.

Two quirks show up in a real response, and both end up visible on the storefront if you pass the values straight through.

title.rendered is HTML-encoded. WordPress converts straight quotes and ampersands into entities, so a title comes back as WordPress 6.7 “Rollins”. Shopify’s article title is a plain String, and a theme will print those entities literally. Decode them. body and summary are typed as HTML, so the rendered content can go in as it is.

date_gmt has no timezone suffix. It looks like 2026-10-27T15:49:21. Shopify’s publishDate is an ISO 8601 DateTime, so append Z to say what WordPress already means.

Which WordPress fields map to a Shopify article?

The mapping is short because Shopify’s ArticleCreateInput is short. title and author are the only required fields.

WordPressShopify ArticleCreateInput
slughandle
title.rendered (decoded)title
content.renderedbody
excerpt.renderedsummary
date_gmt + ZpublishDate
embedded author nameauthor.name
embedded category and tag namestags
embedded featured media source_url, alt_textimage.url, image.altText

The handle matters most. The article URL on Shopify is /blogs/<blog-handle>/<article-handle>, and keeping the WordPress slug means the last segment of every URL survives, which makes the redirect map a pattern rather than a spreadsheet.

Shopify has no categories. Blogs are the only grouping above tags. You could create a Shopify blog per WordPress category, but a post in two categories can only live in one blog, and every blog adds a URL prefix you will need redirects for. Fold categories into tags and keep one blog.

The author input takes either name or userId (a staff account), never both. Both together returns AMBIGUOUS_AUTHOR, and neither returns AUTHOR_FIELD_REQUIRED. Former WordPress authors rarely have Shopify staff accounts, so send the name.

A script to migrate a WordPress blog to Shopify

This runs as a .ts file on a current Node.js release, which strips the types itself (Node.js TypeScript docs). Set SHOP, SHOPIFY_ADMIN_TOKEN and BLOG_ID, and change WP to the old site.

// migrate-blog.ts
const WP = "https://old-site.example/wp-json/wp/v2";
const SHOP = process.env.SHOP!; // your-store.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN!;
const BLOG_ID = process.env.BLOG_ID!; // gid://shopify/Blog/123456789
const API = `https://${SHOP}/admin/api/2026-10/graphql.json`;

type WpTerm = { taxonomy: string; name: string };
type WpPost = {
  slug: string;
  date_gmt: string;
  title: { rendered: string };
  content: { rendered: string };
  excerpt: { rendered: string };
  _embedded?: {
    author?: { name: string }[];
    "wp:term"?: WpTerm[][];
    "wp:featuredmedia"?: { source_url: string; alt_text: string }[];
  };
};

/** Decode the HTML entities WordPress puts in rendered titles and term names. */
function decode(s: string): string {
  return s
    .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
    .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
    .replace(/&quot;/g, '"')
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&amp;/g, "&");
}

async function* wpPosts(): AsyncGenerator<WpPost> {
  for (let page = 1, total = 1; page <= total; page++) {
    const url = `${WP}/posts?per_page=100&page=${page}&_embed=author,wp:term,wp:featuredmedia`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`WordPress returned ${res.status} on page ${page}`);
    total = Number(res.headers.get("X-WP-TotalPages") ?? 1);
    yield* (await res.json()) as WpPost[];
  }
}

async function admin<T>(query: string, variables: object): Promise<T> {
  const res = await fetch(API, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": TOKEN },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  if (json.errors) throw new Error(JSON.stringify(json.errors));
  return json.data as T;
}

const EXISTS = `query ($q: String!) { articles(first: 1, query: $q) { nodes { id } } }`;

const CREATE = `mutation ($article: ArticleCreateInput!) {
  articleCreate(article: $article) {
    article { id handle }
    userErrors { code field message }
  }
}`;

type UserError = { code: string; field: string[] | null; message: string };
const blogNumericId = BLOG_ID.split("/").pop();

for await (const post of wpPosts()) {
  const found = await admin<{ articles: { nodes: { id: string }[] } }>(EXISTS, {
    q: `handle:${post.slug} AND blog_id:${blogNumericId}`,
  });
  if (found.articles.nodes.length > 0) continue; // already imported on an earlier run

  const terms = post._embedded?.["wp:term"]?.flat() ?? [];
  const media = post._embedded?.["wp:featuredmedia"]?.[0];

  const { articleCreate } = await admin<{
    articleCreate: { article: { id: string } | null; userErrors: UserError[] };
  }>(CREATE, {
    article: {
      blogId: BLOG_ID,
      title: decode(post.title.rendered),
      handle: post.slug,
      body: post.content.rendered,
      summary: post.excerpt.rendered,
      author: { name: post._embedded?.author?.[0]?.name ?? "Staff" },
      tags: [...new Set(terms.map((t) => decode(t.name)))],
      isPublished: true,
      publishDate: `${post.date_gmt}Z`,
      ...(media?.source_url ? { image: { url: media.source_url, altText: media.alt_text } } : {}),
    },
  });

  if (articleCreate.userErrors.length > 0) {
    console.error(post.slug, articleCreate.userErrors);
  } else {
    console.log("created", post.slug, articleCreate.article?.id);
  }
}

The existence check is what makes it safe to run twice. Imports fail partway: a token expires, an image URL 404s, the laptop sleeps. The articles query filters on handle and blog_id, so a rerun skips everything already created instead of tripping TAKEN on every handle. It costs one extra query per post, which matters on a big blog. If you have thousands of posts, read how Shopify budgets GraphQL query cost before you run this in a tight loop.

isPublished: true with a past publishDate keeps the original date on the live article. Do not set isPublished: true on a post WordPress had scheduled for the future: Shopify rejects that combination with INVALID_PUBLISH_DATE. The script only reads the default publish status, so it never meets one.

What happens to images in the post body?

The featured image is handled: image.url takes a URL, and the error enum includes UPLOAD_FAILED for when that image cannot be processed. The body is a different problem. content.rendered still points every <img> at wp-content/uploads on the old host, or at a Jetpack CDN URL wrapping it, and those links die when you cancel the WordPress hosting.

Rehost them before you import. Collect each src, decode it (WordPress writes &#038; for the ampersand inside query strings), and pass the URLs to fileCreate with contentType: IMAGE. It takes up to 250 files per call and processes them asynchronously, so poll each file until fileStatus is READY before you read its URL, then rewrite the src in the body you send to articleCreate.

Strip srcset and sizes while you are there. They list every size WordPress generated, all on the old host, and rehosting every size to keep responsive markup that your Shopify theme was never styled for is wasted work.

The same goes for block markup. Rendered content carries classes such as wp-block-paragraph and wp-image-21825, which your Shopify theme has no CSS for. They are harmless. Leave them unless a specific block (galleries, columns, embeds) renders badly, then clean that block type up in the script.

How do you keep the old URLs ranking?

WordPress permalinks are usually /<slug>/ or /<year>/<month>/<day>/<slug>/. Because the handle is the slug, every old URL maps to /blogs/<blog-handle>/<slug> with no lookup table. Generate the pairs from the same API loop and create them with urlRedirectCreate, which takes a path and a target. Category and tag archive pages need redirects too, to the blog or to a tagged blog view. Our 301 map for WooCommerce stores covers Shopify’s redirect limits and why a redirect only fires on a path that 404s, and both apply to blog paths unchanged.

When would you do it differently?

For under 30 posts, copy and paste in the admin. You will spend longer writing and testing the script than doing it by hand, and you get to fix the formatting as you go.

For anything bigger, script it as above, run it against a development store first, and diff the article count against X-WP-Total before you point the domain. Comments do not come across: articleCreate has no field for them. If they matter, that is an app decision, made before cutover.

If the blog is the main reason the site gets traffic and the shop is secondary, question the move itself. A Shopify blog is a basic CMS. Keeping WordPress on a subdomain and putting the store on Shopify is a legitimate answer, and the one I would give a content-led brand.

We move stores onto Shopify, blog and redirects included, as part of our Shopify development work. If you are planning the product side as well, our guide to mapping a WooCommerce catalogue to Shopify covers that half.

Need this built properly?

Get in touch