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.
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
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 GraphQLThis 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
- A dedicated
/api/checkout/place-orderroute that accepts CC data over HTTPS - Input validation and sanitization server-side
- reCAPTCHA v2 verification (required by the gateway) with browser-side widget lifecycle management
- Separate flows for CC vs. non-CC payment methods (the client also offers “Bill Me” for approved accounts)
- 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-accountsfor 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-scopedclass - Replaces
body,html,:rootreferences with the scoped container - Preserves
@media,@supports, and@layerblocks while recursively scoping their contents - Leaves
@keyframesand@font-faceuntouched
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
Productschema with offers, pricing, stock status, aggregate ratings, and individual reviews - Articles: Blog posts with author, publish date, and publisher
- Breadcrumbs:
BreadcrumbListon every page with proper hierarchy - FAQ:
FAQPageschema 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:
Product pages
Category pages
Blog posts
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.
Route handlers
Shared components
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.
Related Insights
ForgeSync: Building a Sage 100 ERP Integration from Scratch
How we replaced a brittle legacy integration with a testable, observable Magento 2 module syncing 422K+ records.
Read MoreThe $50K Hidden Cost of Slow Shopify Sites
Every second of load time costs you 7% in conversions. Fix it without rebuilding.
Read MoreConsidering 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