← Back to Journal

Migrating the Bontique Platform to Magento Headless

Why we chose Magento's GraphQL API and how we rebuilt the Vue/Nuxt frontend for a new reality.

Bontique One is a B2B platform where corporate customers configure gift checks, manage company budgets, run multi-step ordering wizards, and check out. All without ever touching a traditional Magento storefront.

Previously, our platform operated on a fully custom architecture. We had our own backend communicating with the frontend via GraphQL. While this gave us total freedom, we eventually hit a classic scaling wall: maintaining and developing standard e-commerce features (promotions, cart management, checkout) was consuming too many engineering resources.

At our CTO’s initiative, we decided to offload the core e-commerce logic to a professional platform. We chose Adobe Commerce (Magento) as our engine, allowing us to stop reinventing the wheel and focus on the unique value propositions of our product.

Going headless against Magento sounds simple on paper. In practice, it surfaced a handful of architectural decisions across both our Nuxt 3 frontend and our Magento backend that I think are worth sharing.

The Hybrid Architecture: Maintaining Control

We didn’t just “move” to Magento; we integrated it into our existing ecosystem. Based on our requirements, we developed an integration that enabled a hybrid data-fetching scheme:

  1. Magento Headless: Handles the cart, checkout process, and product catalog via its GraphQL API.
  2. Own Domain Backend: Our existing backend remains in place to handle specific business processes that Magento doesn’t cover out-of-the-box, such as complex vouchers design configuration and printing.
  3. Frontend as the Orchestrator: Our apollo client in Nuxt acts as a bridge, aggregating data from two different sources (Magento and our own API) into a unified user interface.

Extending Magento’s B2B Core

Because Bontique is inherently B2B, Magento’s stock B2B module provided a foundation, but we needed to extend it significantly. All custom code lives in a dedicated repository, with a bunch of custom modules. This repository is basically a thin Magento Cloud template. Big thanks to Comwrap, a 3rd-party agency that partnered with us on the transition, for their help.

Key backend domain models include:

  • Company Holdings: allows one administrator to manage multiple subsidiary companies within a unified hierarchy.
  • Dynamic Configurations: to handle complex company configurations (colors, logos, personalization toggles), we bypassed the traditional EAV model. CompanyConfiguration turns company entities into dynamic CMS-like objects backed by a side table, complete with custom validators and save-handler pools.

Headless Checkout: Two Worlds

Our checkout architecture accommodates two distinct user journeys: authenticated B2B users and public guest shoppers.

Authenticated B2B users proceed through the platform utilizing their authenticated session and customized company budgets. However, for our public “shop” (guest orders), we needed a way to handle checkout and redirection without exposing sensitive order IDs.

To solve this, Comwrap built for us a HeadlessCheckout module that introduces a uuid column to the sales_order table. This allowed to replace the default flow with a custom GraphQL operations.

To handle transactions, we integrated Payrexx. While Payrexx offers a Magento module, they do not distribute it as a standard Composer package, generally suggesting a manual “copy-paste” installation into the codebase. To maintain strict, automated dependency management, we bypassed this manual method entirely. Instead, we configured Composer to pull the module directly from github, allowing us to track and lock it cleanly via release tags.

The Frontend: One Apollo Runtime, Two Backends

Our Nuxt 3 SPA talks to two GraphQL backends simultaneously: Magento for commerce primitives and our Domain Backend for check lifecycles and merchant content.

To manage this cleanly, we run four Apollo clients side-by-side: an authenticated and a guest client for each backend.

Every operation in the codebase explicitly names the client it belongs to. That single rule keeps the dual-API reality from leaking into hundreds of files.

Working with ISO date strings everywhere is painful. We composed our Apollo link in stages (headers → scalars → error → terminating link) to handle this transparently.

The Scalars link makes DateTime fields first-class Dayjs objects on the client natively:

const scalarsLinkDateTime = withScalars(generateScalarsLinkPayload(FormatDateTimeServer))
const scalarsLinkDateOnly = withScalars(generateScalarsLinkPayload(FormatDateServer))
const scalarsLink = ApolloLink.split(
  op => op.getContext().dateFormat === 'date-only',
  scalarsLinkDateOnly,
  scalarsLinkDateTime,
)

The Sequential Cart Challenge

Magento’s Cart is a single, server-side aggregate. Any mutation that touches it returns the new cart. If concurrent mutations fire, they either race, deadlock on Magento’s internal locks, or return stale state.

In a multi-step wizard where users rapidly click +/- on a quantity stepper, you can’t just Promise.all everything.

We solved it with useCallableQueue, a tiny composable that serializes async functions. For interactive elements, it collapses intermediate calls so only the latest payload reaches the server. Every product SKU gets its own queue:

const queues = ref<Record<string, ReturnType<typeof useCallableQueue>>>({})

const updateProductQuantity = (productSku: string, quantity: number) => {
  const existingProduct = findWizardProduct(productSku)
  if (existingProduct) {
    existingProduct.quantity = quantity
  }
  if (!queues.value[productSku]) {
    queues.value[productSku] = useCallableQueue(true)   // latest-wins per SKU
  }
  queues.value[productSku].enqueue(async () => {
    await updateProductsInCart({ quantity, sku: productSku })
  })
}

A user mashing the ”+” button produces one in-flight Magento mutation and one final mutation, never a queue of stale intermediate requests.

This serial logic is also critical for cleanup flows. Magento mutations don’t compose, so clearing a cart with applied gift cards must be sequenced by hand:

const clearCart = async () => {
  if (cart.value.applied_coupons?.length) {
    await removeAllCouponCodes()
  }
  if (cart.value.applied_gift_cards?.length) {
    await removeAllGiftCardCodes()  // sequential, not Promise.all
  }
  await clearCartMutation.mutate({ cartId: cartId.value })
}

The “Double Migration” Challenge: From Vue 2 to Vue 3

The most difficult phase was “open-heart surgery.” We needed to switch the data source to Magento’s API while simultaneously upgrading our entire frontend stack.

This was a twofold challenge:

  1. Data Layer Migration: Rewriting the entire API interaction logic to match Magento’s GraphQL schemas.
  2. Paradigm Shift: Moving away from Vue 2 Class-based components to Vue 3 with the Composition API.

Essentially, we were rebuilding the frontend from scratch while changing its “source of truth.” Having a comprehensive Storybook was our literal lifesaver: it allowed us to visually test components while the underlying reactivity and request logic were being completely swapped out.

Architectural Implementation in Vue 3

The Composition API allowed us to cleanly encapsulate Magento-specific logic into reusable composables.

// Example of a typed cart hook
export const useCart = () => {
  // Types are automatically inferred from the generated Magento schema
  const { data, mutate } = useMagentoMutation<AddToCartResponse>(AddToCartMutation);
  
  const addToCart = async (productId: string, quantity: number) => {
    return await mutate({ 
      input: { productId, quantity } 
    });
  };

  return { addToCart };
};

Session State and Cross-Tab Sync

Authentication relies on a single JWT issued by Magento. When a user logs in, Magento generates the token (which we customized to embed the company ID directly in the claims). We then inject this exact same token into both of our authenticated Apollo clients in parallel. This allows the frontend to securely communicate with our custom domain backend using the Magento-issued JWT. To handle expiration gracefully, we decode the token client-side and arm a setTimeout for the exact instant it expires.

Because B2B users often operate with multiple tabs open, cross-tab session sync is mandatory. We use the BroadcastChannel API so one logout means an all-tabs logout.

Type Safety and Strict Error Handling

When working with two independent GraphQL servers, maintaining data integrity is critical. To ensure reliability, we implemented a robust workflow:

  • Schema Introspection: We fetch the latest GraphQL schemas from both servers.
  • Type Generation: Using these schemas, we generate TypeScript interfaces for all queries and mutations.
  • Fully Typed Contracts: This means we work with 100% type safety. If a schema changes on the backend or in Magento, the frontend build will fail, allowing us to catch breaking changes during development rather than in production.

To match this strictness, we wrap every Apollo call. We built wrappers like catchMutationErrors that normalize Apollo errors into { human, technical } pairs, surface toasts automatically, and capture the current Vue instance at setup time. The rule in the codebase is simple: no raw mutate() calls.

Conclusion

Despite a massive technical overhaul - upgrading to Vue 3/Nuxt 3, introducing heavy B2B logic into Magento, and marrying two API schemas, the transition was seamless for the end user. We maintained the UX they were accustomed to while gaining immediate access to Magento’s advanced commerce primitives. By establishing strong boundaries, typed contracts, and robust architectural abstractions between our frontend and dual backends, our team is no longer bogged down maintaining basic cart logic. Instead, we can focus entirely on creating unique domain value.