Skip to main content
Architecture

Building a Headless Magento 2 Storefront: Architecture, Hurdles, and Lessons Learned

A custom Next.js frontend for a B2B industrial distributor. No PWA Studio, no Hyvä, just GraphQL, REST, and hard-won lessons.

April 202614 min readEngineering

When a B2B industrial distributor needed to modernize their decade-old Magento storefront without disrupting their backend operations, we built a fully headless frontend from scratch. No PWA Studio, no Hyvä, no off-the-shelf theme. Just a custom Next.js application that talks to Magento purely through GraphQL and REST APIs.

This is the story of how we connected the pieces, what broke along the way, and what we’d do differently.

Why Headless?

The client’s Magento 2 backend was solid. Their catalog management, pricing rules, customer groups, and ERP integrations all worked. What didn’t work was the frontend: slow page loads, a dated design that didn’t reflect their brand, and a mobile experience that was driving customers to pick up the phone instead of placing orders online.

Rather than re-theme Magento (and inherit its frontend baggage), we decoupled entirely. The Magento admin stays untouched. The storefront is a standalone Next.js app that fetches everything it needs via API.

The Stack

Next.js 16App RouterReact 19Apollo Client 4Tailwind CSS 4TypeScript

The GraphQL Proxy: Hiding the Backend

One of the first architectural decisions was routing all GraphQL traffic through an API proxy rather than having the browser talk directly to Magento.

Browser → /api/graphql → Magento GraphQL

This gives us several things for free:

  • Security: The Magento URL never appears in client-side code or network requests.
  • Mutation whitelisting: We maintain an allowlist of ~40 mutations. Any GraphQL mutation not on the list gets a 403. This prevents creative abuse of the open GraphQL endpoint.
  • Rate limiting: Sliding-window rate limits per IP, per endpoint. 200 requests/minute for general queries, 10/minute for order placement, 5/minute for form submissions.
  • Auth forwarding: Customer Bearer tokens pass through cleanly while staging credentials stay server-side.

This proxy pattern added maybe a day of work upfront but saved us from an entire category of security headaches.

The REST API Gap

Nobody warns you about this

GraphQL doesn’t expose everything. Two critical features had no GraphQL API whatsoever.

Product Specifications

Magento’s product attributes (the detailed spec tables that B2B buyers rely on) aren’t available through the standard GraphQL schema in a format that gives you attribute labels, sort order, and visibility settings. We needed the full admin-curated spec sheet, not raw attribute values.

Solution: A REST API helper that fetches product attributes through Magento’s REST endpoints, caches them server-side for 5 minutes, and serves them as structured data. The admin token gets cached for 4 hours with automatic refresh.

Q&A Module

The client used a third-party Q&A module (think product questions and answers). Zero GraphQL support. Not even documented REST endpoints. We had to reverse-engineer the API from the module’s source code.

Solution: A dedicated API proxy route that fetches approved Q&A entries with a fallback across two endpoint patterns (the module changed its API between versions), submits new questions with rate limiting and input validation, and caches responses for 5 minutes with stale-while-revalidate.

The lesson: if you’re planning a headless Magento build, audit your third-party modules early. Every module without a GraphQL API becomes a REST proxy route and a maintenance surface.

The Payment Gateway Problem

This was the single biggest hurdle of the project.

The client’s payment gateway was a legacy credit card processor that uses server-side SOAP tokenization. Not Stripe. Not Braintree. Not any gateway with a modern JavaScript SDK that handles PCI compliance on the client.

This meant credit card data had to travel from the browser to our server, then to Magento’s REST API, which would handle the SOAP call to the processor. In a headless setup, that’s a chain you have to build yourself.

What we built

  1. A dedicated /api/checkout/place-order route that accepts CC data over HTTPS
  2. Input validation and sanitization server-side
  3. reCAPTCHA v2 verification (required by the gateway) with browser-side widget lifecycle management
  4. Separate flows for CC vs. non-CC payment methods (the client also offers “Bill Me” for approved accounts)
  5. PO number and carrier account details posted as order comments after placement via a second REST call

The checkout page itself went through three major iterations. The first version was a 1,400-line monolith. We eventually extracted it into focused step components (contact/shipping, shipping method selection, and payment/billing) while keeping state management centralized in the page.

Key takeaway: If your client uses a non-standard payment gateway, budget 3–4x the time you’d expect for checkout. The happy path works fast. The edge cases (expired tokens, reCAPTCHA failures, partial order states) take forever.

B2B Checkout: It’s Not Consumer E-Commerce

B2B checkout has requirements that consumer platforms never think about:

“Ship on My Account”

Many B2B buyers have their own UPS or FedEx accounts and want freight billed directly to their carrier. We built a carrier account management system with:

  • Saved accounts stored as a Magento custom attribute (JSON-serialized, since Magento has no native carrier account entity)
  • Account number masking in the UI (show last 4 digits only)
  • Guest support via localStorage with the same UI
  • A dedicated profile page at /account/shipping-accounts for managing saved accounts

PO Numbers

Every B2B order needs a PO number. It sounds trivial, but the PO needs to persist across payment method changes, survive page refreshes during checkout, and get attached to the Magento order in a way that’s visible in the admin. We save it both as a quote extension attribute (pre-placement) and as an order comment (post-placement) to cover all bases.

Bulk Quote Requests

Product pages include a “Request Bulk Quote” button that opens a modal with contact details and quantity fields. The request goes through our contact API with honeypot spam protection and auto-reply emails via Resend.

CMS Content: Scoping Magento’s HTML

Magento’s CMS and Page Builder generate HTML with embedded <style> blocks and inline JavaScript. Rendering that inside a Next.js app without it bleeding into your layout is a real problem.

We built a CSS scoping engine, a character-by-character parser (not regex) that:

  • Wraps all CSS selectors under a .cms-scoped class
  • Replaces body, html, :root references with the scoped container
  • Preserves @media, @supports, and @layer blocks while recursively scoping their contents
  • Leaves @keyframes and @font-face untouched

On the HTML side, a directive processor handles Magento’s template syntax:

  • {{media url="..."}} → proxied through our media endpoint (with auth)
  • {{store url="..."}} → relative path
  • {{widget}}, {{block}} → stripped (these don’t work headless)

The sanitizer runs in two modes: strict (strips all scripts) for user content, and trusted (preserves Page Builder inline JS) for admin CMS content.

SEO: Structured Data and Sitemaps

Search engines were a priority. The old Magento frontend had good SEO, and we couldn’t afford to lose it.

JSON-LD Structured Data

Every significant page type gets structured data:

  • Products: Full Product schema with offers, pricing, stock status, aggregate ratings, and individual reviews
  • Articles: Blog posts with author, publish date, and publisher
  • Breadcrumbs: BreadcrumbList on every page with proper hierarchy
  • FAQ: FAQPage schema on the FAQ page with all Q&A pairs
  • Organization: Company details, social profiles, contact info in the root layout

Dynamic Sitemap

The XML sitemap (/sitemap.xml) is generated at request time, paginating through all products, categories, blog posts, and blog categories via GraphQL. Change frequencies and priorities are set per content type. It uses Promise.allSettled so a single failed fetch doesn’t kill the entire sitemap.

ISR Strategy

Not everything needs to be fresh every request:

15 min

Product pages

30 min

Category pages

1 hour

Blog posts

5 min

Search results

This gives us the SEO benefits of static HTML with near-real-time data freshness.

Performance Patterns

Suspense Streaming

Category pages use React Suspense to stream the product grid independently of the page shell. The hero, breadcrumbs, and subcategory cards render instantly while the product query (which can be slow with filters) streams in with a skeleton fallback.

Caching Layers

We cache at multiple levels:

  • ISR: Page-level static regeneration
  • REST API cache: Server-side 5-minute revalidation for product specs and config values
  • Admin token cache: 4-hour in-memory cache with preemptive refresh
  • Shipping rate cache: 15-minute in-memory cache keyed by weight + ZIP
  • Apollo in-memory cache: Client-side with custom merge strategies for paginated product lists

React.memo

Components that render inside lists (product cards, filter groups) are wrapped with React.memo to prevent unnecessary re-renders when parent state changes but props haven’t.

Security Considerations

Beyond the GraphQL proxy and mutation whitelist:

  • Content Security Policy allows only self-hosted scripts, Google reCAPTCHA, and Google Fonts
  • HSTS with a 2-year max-age
  • Honeypot fields on all public forms (hidden input that bots fill, silently drops the submission)
  • Timing-safe comparison for staging auth (prevents timing attacks on passwords)
  • Rate limiting on every public API endpoint with per-IP sliding windows
  • Input validation on all form submissions: phone formatting, ZIP validation, HTML stripping, length limits

What We’d Do Differently

Type the GraphQL responses from day one

We started with any types on Apollo queries to move fast and paid for it later with a multi-day cleanup pass. Magento’s GraphQL types are deeply nested, but even lightweight interfaces catch real bugs.

Audit every module’s API surface before committing

Two modules without GraphQL support cost us weeks of REST proxy development. If we’d known upfront, we could have evaluated alternatives or budgeted accordingly.

Start with component extraction, not monolithic pages

Our checkout page grew to 1,400 lines before we split it. The PDP hit 800. Starting with focused components from day one would have saved a refactoring pass.

Centralize constants immediately

We had the site URL defined in 11 files, the phone number in 10. A single config file took 20 minutes to create but touched 21 files to retrofit.

The Result

The final storefront is a sub-second loading experience across all page types. The Magento admin works exactly as it always did (product management, order processing, customer groups, pricing rules) but the customer-facing experience is entirely custom.

72

Route handlers

22

Shared components

Zero

Magento frontend code

The client’s admin team manages featured products by dragging items into a Magento category. They publish blog posts through their existing blog module. They update CMS pages through Page Builder. None of that changed. What changed is that their customers now get a fast, accessible, mobile-first buying experience, built on a stack that their development team can actually maintain.

Considering a headless Magento build?

Every project has different integration points. Let’s talk through the architecture decisions that matter most for your setup.

Get in Touch