Migration

How to Migrate a Marketing Site From WordPress to Shopify in a Week

A WordPress site on Elementor was the platform we'd been quietly recommending against to clients for two years. Every Shopify build delivered ended up faster, cheaper to maintain, and easier to edit. The credibility gap was getting awkward. This is the engineering story of moving the marketing site onto Shopify - what we kept, what we threw away, what surprised us.

Why Shopify for a service site

Shopify is for stores. We don't sell anything on the marketing site. So the obvious question is: why not Webflow, Framer, or just a static Next.js site?

Three reasons:

  1. We live in Shopify every day. Every account team spends hours daily inside the Shopify admin building product launches, automations, and analytics for clients. The cost of a context-switch to a separate marketing-site CMS is real. Putting the marketing site in the same admin as our clients means anyone on the team can update copy, swap an image, or add a blog post without filing a ticket with the developer.
  2. The Horizon theme is genuinely good. Shopify shipped Horizon (v3.4.0 at the time of writing) as a first-party theme with a real section + blocks architecture, color schemes, modern bundling, and a clean Liquid layout. We use it as a base for client builds. Eating our own dog food on the marketing site was a forcing function.
  3. One platform, one bill, one auth, one backup. Shopify gives us a CDN, blog engine, form handling, customer database, transactional email, and a built-in Klaviyo integration. For a service site that publishes the occasional article and runs lead forms, that's already 90% of what we need.

Could we have built this on Webflow in less time? Yes, probably. We chose the slightly harder path because the long-term cost of maintenance is what we were optimising for.

What we were migrating from

The WordPress site was Elementor-built. Five years of accumulated sediment: dozens of plugins, page builders fighting each other, custom CSS injected per-page, two analytics scripts loading the same library, fonts loaded three different ways, and a hosting bill that climbed every renewal.

Concrete inventory:

  • 7 marketing pages (homepage, about, paid-ads, email-sms-marketing, website-development, seo, case-studies)
  • 30 individual case study pages
  • 29 published blog posts
  • 7 video testimonials
  • 28 client brand logos
  • 12 team photos
  • A custom OTF brand font, self-hosted, 4 weights
  • ~196 images across hero shots, screenshots, marquee creatives

Everything else (the cookie banner, the security plugin, the SEO plugin, the cache plugin, the affiliate plugin, the form plugin, the analytics plugin, the SG Optimizer, the 14 widgets nobody could remember what they did) got dropped.

The north star: same content, different codebase

We were very clear with ourselves about what this project was and was not.

It was: a structural migration. Same words on the page. Same hero copy. Same testimonials. Same case study headlines. Same metric numbers. Same images, sourced from the live WordPress media library.

It was not: a rebrand. Not a redesign. Not "while we're at it, let's rewrite the homepage copy." That conversation comes after the migration, because trying to do both at once is how one-week projects become six-month projects.

We wrote this rule into a MIGRATION_POLICY.md at the repo root on day two. The exact rule, copy-pasted:

100% content and structural parity with the live site. If the Liquid theme says anything the live site does not say, or omits anything the live site says, or arranges sections differently, that is a defect.

Having that in writing kept us from "polishing" things mid-flight. Every section was a strict port.

Source-of-truth discipline

The live site is behind Cloudflare bot protection. We couldn't programmatically fetch pages with a normal HTTP request. So before any code got written, we scraped all 7 marketing pages plus their asset directories into a static/ folder at the project root:

static/
  homepage.html
  homepage_files/
  about-us.html
  about-us_files/
  case-studies.html
  ...

This became the canonical reference. The rule was: if you want to know what the page says, open static/<page>.html and find the text. If you want to know what color a section's background is, open the compiled CSS in <page>_files/ and look at the actual computed style. Screenshots are useful only for confirming "did I match" at the end, not for deciding "what is the truth" at the start.

This rule felt pedantic for the first three days and saved us a week of rework over the following ten. We caught at least a dozen subtle differences (a missing eyebrow, a stat in the wrong order, a section we thought was dark but was actually light grey on live) only because we had static/ as the unambiguous source.

For the asset library, we used read-only SSH into the live WordPress server. Anything missing from the local static/<page>_files/ directories was pulled directly from wp-content/uploads/2026/<MM>/ over SCP. No writes. No WP-CLI commands that modified state. The live site stayed untouched throughout the migration.

Architecture

We built the rebuild on top of Horizon's existing structure rather than ripping it out. Shopify's Horizon theme uses:

  • Sections - large reusable page-level components with their own schema, defined in sections/<name>.liquid. Each section emits its own scoped CSS and JS.
  • Blocks - smaller composable pieces of UI used inside sections, in blocks/<name>.liquid. Horizon ships with ~96 blocks for common patterns.
  • Section groups - JSON files that combine sections into the header and footer chrome.
  • Templates - JSON files in templates/ that compose sections into a page.

We added 28 brand-specific sections, all prefixed with the brand namespace, that wrap or replace Horizon's defaults for marketing-page-specific layouts: a hero with eyebrow + accent heading + body + dual CTAs + auto-scrolling case-card collage; the workhorse two-column text/image section with image left/right/below; horizontal-scroll case study slider for the homepage; the 30-card case study grid; per-case-study detail page layout; team profile grid; three-up feature cards with stat-value variant; large numeric stats row; client video testimonials; five-star text reviews; combined CTA + FAQ panel; the contact form (block-driven, used on every page); client logo carousel with automatic dark/light inversion; partner badges; "READY TO WORK WITH US?" infinite marquee; custom blog article layout; homepage and index blog card grids; stat bands for service pages; "ongoing vs one-off" comparison cards; the "What makes us different?" past-brand collage; 72-image scrolling creative collage; service card grid; pricing CTA panels; standalone FAQ accordion; radial-glow CTA panels; social channels band; horizontally scrolling team carousel; footer partner badges + link columns.

Every section has a full Theme Editor schema with sensible defaults and presets. That means the team can add or rearrange any of these on any page from the visual editor without touching Liquid.

The per-section process

For every section, we followed the same non-negotiable order:

  1. Open the static HTML for the page. Find the section by searching for its eyebrow or heading text.
  2. Extract the real content by Elementor class: eyebrow from .elementor-icon-list-text, heading from .elementor-heading-title, body from .elementor-widget-text-editor, buttons from .elementor-button, repeating sub-blocks from each container's children in DOM order.
  3. Extract every asset filename the section references. For each, verify it exists in assets/. If not, SCP it from wp-content/uploads/YYYY/MM/<file>. Never ship a section with missing images.
  4. Identify the actual background color from the inline style attribute or the compiled CSS. Set the matching Shopify color scheme. We had six schemes wired up: brand-dark + cyan, brand-white, light grey, cyan-dominant, deep-dark, dark grey.
  5. Mirror the DOM structure in Liquid. If live had a 3-image collage, mirror that. If live had stacked cards with image-top + quote-bottom, mirror that. The Liquid output should read like a hand-rewrite of the live HTML, not a re-interpretation.
  6. Render at the dev server, compare side-by-side with the static HTML, fix every difference before moving on. No batching.

That last step is where 80% of the value came from. We did not allow ourselves to mark a section "done" without rendering it locally and visually diffing against static/<page>.html at desktop 1440 and mobile 375.

The extraction in step 2 was eventually automated with a small Python script (qa/extract-static-sections.py) that parses any of the 7 static HTML files and emits a JSON map of every section's headings, copy, buttons, and child structures. That saved a lot of manual class-hunting and removed a class of "we forgot a sub-heading" bugs.

Brand tokens

Before building any sections we extracted the brand's design tokens from the live site's compiled CSS - colors (primary text white, accent cyan, dark background, muted body text at 60% alpha, plus cyan family for gradients), and typography (single brand font, custom OTF, 4 weights, with explicit sizes for display H1, H2, body, eyebrow).

These went into a tokens CSS file and a snippet for font-face declarations with preload. The Theme Editor's color scheme defaults in config/settings_schema.json were overridden so Shopify's editor reflects the brand out of the box.

One subtle thing we got right early: we used font-display: optional on the brand font rather than swap. With swap, the body font flashes from system-default to the brand font after ~100ms, which on a slow connection looks broken. With optional, the brand font is used only if it loaded within the first ~100ms; otherwise the page renders in the system fallback for that visit and the brand font is cached for the next one. The result is no visible font flash, ever.

Content migration

This is where you can either spend a week clicking through pages copy-pasting, or spend an afternoon writing a script. We did the script.

WordPress stores Elementor page data as serialized JSON in the _elementor_data post meta field. Every Elementor widget, with its content, settings, image references, and child widgets, is in that blob. So we did one large WP-CLI export over SSH:

wp post meta get <post-id> _elementor_data

for every page, every case study, every blog post. That gave us 51 page records, 30 case studies, 29 blog posts, plus all the testimonial CPTs (video testimonials, text reviews, client logos). Total of ~4 MB of structured JSON, gitignored to wp-content/json/.

A Python script then walked the Elementor JSON for each case study, pulled out: brand name and brand logo, the hero metric (e.g. "111% increase in revenue YoY"), the "Platform / Industry / Service" tag row, the brand intro paragraph, the "What were they struggling with?" body, the "Outcomes & Results" body plus extracted metric cards, the "What did we do for them?" body, and the case study's hero thumbnail filename.

It output 30 cleaned-up JSON files, one per case study, which we then fed to Shopify's Admin API via pageCreate mutations. Each case study became a real Shopify Page at /pages/case-study-<slug> with a custom template templates/page.case-study-<slug>.json referencing a shared case-study-detail.liquid section.

The blog posts went through articleCreate. Featured images were uploaded via stagedUploadsCreate followed by fileCreate to Shopify's CDN. Tags were auto-derived from the article body. Original publish dates were preserved so nothing looked artificially "just published."

By the end of the content-migration phase we had 38 real frontend URLs (homepage + 7 pages + 30 case studies) returning HTTP 200 with real content, plus 29 blog posts in the news blog under a custom article template.

The form

Every page on the live site ended with a "GET IN TOUCH" anchor and a discovery form. On the live WordPress site this was a Fluent Forms widget with Cloudflare Turnstile, four hidden ID fields, and per-page question variations.

We built a single block-driven Liquid section instead. sections/discovery-form.liquid is one section. Every page's form is configured by listing field blocks in that page's template JSON, with block types text_field, textarea_field, radio_group, checkbox_group, legend. Each emits the right HTML and wires up to Shopify's contact form tag so submissions land in the agency inbox. Naming follows contact[<name>] so the Shopify Admin sees a clean key/value map per submission.

Klaviyo wiring. The section reads two theme-level settings plus optional per-section overrides. When the public API key is set, the JS handler does the following on every submit:

  1. Collects all named inputs into a payload object. Checkboxes preserve their array shape.
  2. Fires a Klaviyo event via the client-events endpoint with the section's event name as the metric. Properties are the full payload.
  3. If the form has an email or phone, creates a Klaviyo subscription against the configured list.
  4. Always submits to Shopify's contact form, even if the Klaviyo calls fail. Shopify is the durable record. Klaviyo is the marketing pipe.

We use Klaviyo's public company_id (the 6-character public site ID), not a private API key. Private keys must never be embedded in a public theme, because anyone could read the source and blast events into your account. Klaviyo's client endpoints exist specifically to be safe to use from the browser with only the public ID.

Bot prevention. Four layers, all client-side, all baked into the section:

  1. Honeypot. A hidden input rendered offscreen with tabindex="-1" and aria-hidden="true". Real users can't see or focus it; spam bots fill every input they find. If non-empty on submit, the JS aborts the submit.
  2. Dwell-time. JS stamps Date.now() into a hidden input on page load. Submissions within 3 seconds of load are blocked. Most bots submit instantly.
  3. JS-required submit. The validation lives in a submit event listener. Non-JS scrapers can't bypass either check.
  4. Cloudflare Turnstile. When a Turnstile site key is configured in theme settings, the section mounts the widget above the submit button and requires a non-empty response token before submission. Falls back gracefully (form still has layers 1-3) if the Cloudflare script is blocked or fails to load.

This catches the vast majority of automated spam without any visible friction for real users.

Performance

Modern Horizon is fast out of the box. We did three optimisations on top:

  1. content-visibility: auto on every below-fold section. The browser skips layout/paint for content outside the viewport until it scrolls into view. Big win on the homepage, which has 16 sections.
  2. requestIdleCallback gates marquee animations until window.load plus an idle window. The "READY TO WORK WITH US?" marquee, the partner-badges marquee, and the creative-collage marquee all start animating only after the page is genuinely idle, so they don't compete with the initial paint.
  3. Passive scroll listeners on the document and Horizon's internal scroll handler. The default Horizon listener was non-passive, which on iOS Safari causes a small jank when the user starts scrolling. Marking it passive removed the jank.

We also debugged a "page freezes for a moment after navigation" report on Brave that turned out to be browser extension overhead, not our code. Clean Chrome had no freeze. Worth a one-paragraph mention for anyone hitting similar reports from clients: always reproduce in an extension-free browser before chasing the bug in code.

Color schemes

Horizon's color scheme system was perfect for this. We configured six schemes in config/settings_data.json: brand dark with cyan accents, brand white with dark text, light grey alternate, cyan-dominant, deep-dark for FAQ panels, and dark grey for CTA cards.

Every section's schema exposes a color_scheme setting so a page editor can re-skin a section without code. Dark and light alternate down the homepage to give the eye a rhythm: dark hero, light stats, dark partners, light brand strip, dark FAQ, light blog, dark marquee. This was a deliberate copy of the live site's pattern; we discovered we had it wrong on the first pass when the build was all-dark and looked like a wall.

One useful trick for client logo collages that need to render on both dark and light schemes: brand logos as black silhouettes by default, with filter: invert(1) applied automatically only on the dark schemes via:

.color-scheme-1 .brand-strip__logo,
.color-scheme-5 .brand-strip__logo,
.color-scheme-6 .brand-strip__logo {
  filter: brightness(0) invert(1);
}

That means we shipped one logo per brand and got both colour treatments for free.

Two things that bit us

1. Specificity wars on the hero variant.

We added a heading_size: hero variant to a text-image block that uses a different grid layout: large heading on the left, body and CTAs on the right. The selector had three classes, higher specificity than the mobile breakpoint rule which had only two. Result: on mobile, the hero variant kept its two-column grid and the right column got pushed off-screen. Easy fix once spotted; add the hero variant to the mobile-collapse selector. But finding it took an annoying half-hour because every other mobile breakpoint worked fine.

Lesson: when you add a variant with multi-class specificity, audit every responsive rule for the same combination.

2. Article body content from Elementor.

The migrated blog posts contained Elementor's FAQ accordion widget, which exports as raw <details><summary> HTML with two unsized SVG icons inside the summary (one minus, one plus, for the open and closed states). With no CSS constraint, those SVGs rendered at full size - each plus icon was about 300px tall, dwarfing the page.

Fix was a small block of CSS in the article body section that constrains summary > svg to 18px, hides the minus when closed and the plus when open, and styles the row like the homepage FAQ. We also darkened in-body link colours from cyan to a readable teal on light scheme pages because cyan on white is illegible.

And tables. Migrated articles included wide comparison tables that overflowed the viewport on mobile and dragged the whole page horizontally. CSS-only fix:

.article__body-inner { overflow-x: hidden; }
.article__body-inner table {
  display: block;
  width: 100%;
  max-width: 100%;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
}

The table becomes its own horizontally-scrollable block inside the article. The article column doesn't widen, so the page doesn't widen.

Tooling

A few small utilities made the build dramatically faster:

  • Admin API push script. The Shopify Theme CLI is great for local dev but slow for batch pushes of dozens of files. We wrote a small Python wrapper around the themeFilesUpsert GraphQL mutation that uploads any number of files (text or base64) to the main theme in one request. Most of the asset migrations and Liquid edits went through this rather than the CLI.
  • Side-by-side dev server. shopify theme dev on port 9292 ran continuously. Every section change reloaded in the browser within a second. The static reference files were served by a separate python3 -m http.server on port 1111 so we could open both at once and visually compare.
  • qa/extract-static-sections.py. Parses any of the 7 static HTML files and outputs a JSON map of every section's content. Removes the "did I miss an eyebrow" class of bug.
  • Pixelmatch visual diff. We tried Playwright + pixelmatch for automated visual regression. It hovered around a 40-60% per-pixel diff, which sounds bad but is mostly an artifact of section-height variation compounding down the page. We stopped relying on the percentage and used the diff images instead, which show exactly which regions differ. Useful, not a hard pass/fail.
  • Read-only SSH to the live server. SFTP / SCP / find only. No mutating commands. We pulled assets as needed without ever risking the live site.

What we'd do differently

Pull all assets up front. We pulled assets section-by-section as we encountered them. About a third of the time we'd start building a section, realise we needed three images we didn't have locally, SCP them down, then keep going. Faster to do one giant asset pull on day one and only fall back to SSH for the rare missing item. Cost us maybe half a day of context-switching.

Build a per-page Liquid validator earlier. We had a JSON validator command but only sometimes ran it. A pre-commit hook that validates every template JSON file in the repo would have caught two broken templates that went live for ~20 minutes each before someone noticed.

Decide the form provider before building the form section. We built the discovery form against Shopify's contact form first, then bolted Klaviyo on top, then added Turnstile. Each layer added settings to the schema and JS to the section. If we'd known up-front the final architecture was "Shopify + Klaviyo + Turnstile + honeypot + dwell-time", we'd have built it cleanly in one pass instead of iterating three times.

Cache the Admin API token. Shopify's Admin API tokens via the client_credentials grant last 24 hours. Half the script runs ended with "token expired, re-auth" because we kept losing the cached token across sessions. Trivial fix: write it to a file with a timestamp and skip re-auth if less than 23 hours old. Did it eventually. Should have done it on day one.

Set up the Theme Editor previews from day one. Every section has a full schema, but we built sections by editing template JSON files directly because that's faster while iterating. The team can now edit everything in Theme Editor, but they couldn't on day three or four when they wanted to. Worth shipping the Theme Editor experience earlier as part of "definition of done" per section, not as a final polish step.

Numbers

Final state of the rebuild:

  • 28 brand-specific Liquid sections
  • 49 page and article templates
  • 196 brand-specific assets (fonts, logos, partner badges, case study thumbnails, team photos, video testimonial thumbnails, creative collage images, icons)
  • 38 frontend URLs all returning HTTP 200 with real content
  • 29 blog articles migrated with original publish dates and featured images
  • 1 contact form powering 38 different page-specific lead-capture forms
  • 4 layers of bot prevention (honeypot, dwell-time, JS-required submit, Cloudflare Turnstile)

Page-load Lighthouse on the homepage went from a "needs improvement" 67 on the live WordPress site to a clean 96 on the Shopify rebuild on cold cache. Time-to-interactive dropped from ~3.2 seconds to ~1.4 seconds.

Closing

The reason this migration was achievable in a week of focused work was the discipline of two rules:

  1. Same content. Don't redesign while you migrate.
  2. static/ is the source of truth. Not screenshots, not memory, not "what feels right."

Without rule 1, the project would have ballooned into a redesign. Without rule 2, every section would have had three subtle parity bugs that compound across 38 pages.

If you're considering a similar migration for a service business - moving off a heavy WordPress stack onto something modern - the pattern is the same. Pick the platform that matches how your team already works. Lock the content. Scrape the truth. Port section by section. Validate every page against the source. The rest is execution.

Filip Rastovic
Filip Rastovic
Shopify Developer & CRO Specialist · Stargazer Studio

Migrating a marketing site to Shopify?

Whether it's WordPress, Webflow, or a hand-rolled stack - I handle structural migrations with full SEO equity preserved. Send a brief and I'll quote a fixed price within 24 hours.

Book a free call More articles
Filip Rastovic
Book a Call Get started today