Skip to content
ai·06.10.2026·8 min read

Migrating a JTL-Shop to Shopify: What to Import and What to Skip

There is no one-click connector from JTL-Shop to Shopify. Here is how I planned a real migration - what I imported, what I deliberately did not, how scripts verify the result, how to stop notification mails during an order import, and the launch checklist.

Legacy shop with an encrypted database feeding a verified import pipeline into a Shopify storefront

This week I moved a small D2C cosmetics shop from JTL-Shop 5 (the storefront of the German JTL ERP ecosystem) to Shopify. It was a one-person project with AI agents doing the grunt work, and it produced a few lessons I would have liked to read first. None of them are about the import button, because there is no import button.

There Is No Connector, So Plan What You Actually Need

I asked the obvious question first: is there a connector or export that takes JTL-Shop to Shopify? There isn't a one-click one. The JTL storefront only exposes active articles, and in this project the ERP was being retired, so Shopify became the leading system. That decision shapes everything else, so I wrote it down before touching data:

  • The ERP goes away. Shopify is the source of truth afterwards.
  • Single articles that are really sizes of one product become variants. The old shop had a variant "parent" article that was never purchasable; it only carried a display price.
  • Categories become collections, with the old tree rebuilt as a menu.
  • Reviews, bundles and back-in-stock notifications are only migrated if the old shop actually used them.

The product import itself runs through Shopify's native product CSV. Collections and blog articles need either Matrixify or the Admin API; redirects go through the native redirect CSV. I generated all of it with scripts from read-only access to the old shop, so the legacy system was never modified.

Merge Duplicates Before Import, Not After

The old catalog had the classic mess: the same product once as a single article and once as a variant, and handles like …_1 and …_2 that were really artifacts of the old URL scheme. Cleaning this inside Shopify afterwards means broken reviews and redirects. I resolved it in the mapping files first and kept a decisions.md log of each call.

That log paid off two days later. Someone asked why a product "was missing" in the new shop. It was not: the URL belonged to a non-purchasable variant parent in JTL (it only carried a display price, with three real sizes behind it). The decision was in the log, the old URL had a 301 to the merged product, and the answer took one lookup instead of a debugging session. If you merge products, write down which old URL maps to which new variant.

Orders: The Encrypted Fields Problem

Order history is where it got interesting. JTL-Shop 5 stores surname, company and street encrypted in the customer and address tables. A raw database dump therefore gives you order numbers, amounts, e-mail, postcode and city, but not the name or street.

I laid out four options:

OptionEffortResult
Export from the ERP (its database holds plaintext)lowfull data
Decrypt with the shop's key via a scriptmedium, needs server accessfull data
Anonymized importlowhistory and revenue, no addresses
No old orders at allminimalfresh start, old system as archive

Full data was preferable, but the ERP export was not available, so I went with the anonymized import. What it keeps: order number (as a tag and a custom attribute), date, line items, amounts and taxes, e-mail, postcode, city, country, payment and shipping status, tracking numbers. What it deliberately drops: surname, street, company, phone and customer comments.

Two details that cost me time:

  • Shopify discards a whole address if required fields are empty, taking postcode and city with it. A - placeholder in surname and street keeps the rest.
  • Shopify does not let you set the fulfillment date on import, so the real shipping date goes into a custom attribute.

Also: invoices still have to be retained under German bookkeeping rules, so the old system or an archive stays relevant. Shopify does not need to replace it.

The Import Script: Resumable, Throttled, Idempotent

orderCreate does not work from an admin browser session, so I created a custom app in the dev dashboard with write access for orders and customers, to be uninstalled afterwards. The script is a plain Node process, not an agent task:

// Sketch: one order per call, append-only log makes restarts safe
const done = new Set(readLines('import.log').map((l) => JSON.parse(l).legacyNo))

for (const order of legacyOrders) {
  if (done.has(order.number)) continue

  const res = await gql(CREATE_ORDER, {
    order: {
      email: order.email,
      processedAt: order.date,
      tags: [`legacy-${order.number}`],
      customAttributes: [{ key: 'legacy_no', value: String(order.number) }],
      lineItems: order.items,
      // address with '-' placeholders for dropped fields
    },
    options: { sendReceipt: false, sendFulfillmentReceipt: false },
  })

  if (isThrottled(res)) {
    await sleep(65_000)
    continue
  } // retry same order
  appendLine('import.log', { legacyNo: order.number, id: res.order.id })
}

Reality check from the run: the dev store allows roughly five new orders per minute through the API, so about 1,600 orders take hours, not minutes. The first process died in the evening, most likely because the laptop went to sleep, so I wrapped it in caffeinate and later moved it to a launchd job. Because the log is append-only and checked on start, restarting never created duplicates. Six orders with bundle products failed because Shopify does not accept bundle variants as line items; the script now creates those as custom line items with title and SKU.

Silence Notifications Before You Import, Not After

I thought mails were off. I had disabled abandoned-checkout and the other marketing-style automations, and I used sendReceipt: false. Then the shop owner's inbox filled with "New order" mails.

sendReceipt: false only suppresses customer mail. The staff notification "New order" fires for every order created via the API. I stopped the import and switched off that notification. After the import had resumed and the mails kept coming, I switched off all six staff notifications (including the weekly summary) until the import was done. The mails already sent cannot be recalled.

So, before any bulk order import:

  1. Turn off every staff notification, not just the obvious one.
  2. Keep customer notification templates off while the store is not live.
  3. Put "re-enable New order and Order dispute updates" on the go-live list. Forget it and you lose real orders after launch.

Verify With Scripts, Not With "Looks Right"

A progress counter is not a verification. I set up a separate verify script to run after the import, with three checks against the legacy export: order count, revenue per year, and cancel status. Counting alone misses a half-imported year; revenue per year catches missing or duplicated orders and wrong tax handling; cancel status catches the case where cancelled orders silently become paid ones. I also spot-checked the first 16 imported orders against the legacy totals (amounts, payment status, shipping status, voucher codes, tracking numbers).

The same principle applies to redirects. The 560 redirects I had in the end were tested as data: no duplicates, no loops, no chains, every target exists, and every URL from the old sitemap answers with a 301 to a 200. If you want redirect and on-page checks as a repeatable crawl, I wrote about building an SEO crawler.

Blog: Easy to Move, Easy to Get Subtly Wrong

Shopify has a native blog with several blogs, tags, featured images, SEO fields and custom handles. The JTL blog (71 articles) has no clean export, so I read it from the database tables and the live pages. Image files had to be re-uploaded to Shopify Files and URLs inside article bodies rewritten. Two traps: the blog handle has to match the old URL prefix, or the redirects point to 404; and the old SEO titles import unchanged, so I rewrote suffixes, lengths and truncated descriptions across all articles afterwards.

Launch-Readiness Check

Before launch I had the products checked for SEO and SEA readiness. The verdict first time round was "not ready", and the list is a useful template:

  • Store was still a development store (test payments only), payment providers incomplete.
  • Domain not connected, password protection still on.
  • Homepage title and meta description missing, no social image.
  • Product meta descriptions cut off mid-word or formulaic, with typos; two products sharing the same title.
  • Inconsistent title suffixes across products and collections.
  • A utility collection still publicly reachable.
  • Cookie consent and tracking pixels (they must not fire before consent).
  • Legal texts still mentioning the old payment providers and the old ERP.

I also removed unprovable health claims from the meta descriptions, which is a risk for ads and under German health-advertising law.

What I Would Do Again

Decide the system of record first. Merge duplicates before import. Import anonymized history rather than block on data you can't get. Run bulk operations as resumable scripts, not as agent sessions. Write the verify script before the import, and write the notification checklist before the first test order.

If you are planning an ERP-shop to Shopify move and want someone to build the mapping, the scripts and the launch checklist, get in touch.

Enjoyed this?

Get new posts as they land.

Subscribe via RSS

Keep reading