Chat on WhatsApp
Headless & Architecture 11 min read

Magento + React Server Components — Is It Ready in 2026?

React Server Components went stable in Next.js 14. For Magento, that means a product grid shipping zero client JavaScript and a PDP streaming from the server in under 200 ms. Two problems nobody has solved cleanly yet: cart state (per-session, stays client) and JWT refresh (cookie-bound, but Magento's token-refresh endpoint expects a client trigger). This post shows the real Next.js 14 code for a Magento RSC storefront, the boundary between server and client components, and a bundle-size comparison vs PWA Studio that explains why the rebuild is worth it on some projects and a waste on others.

Magento + React Server Components — Is It Ready in 2026?

React Server Components for Magento are a headless storefront pattern that uses Next.js 14+ async server components to fetch Magento GraphQL on the server, render HTML, and ship a fraction of the client JavaScript that PWA Studio or a plain Next.js Pages Router build requires in 2026 that happens because RSC lets the server own data fetching for the product grid, the PDP, and the category page while the cart panel remains a client component. The trade-off is two integration problems — cart state and JWT refresh — that need explicit boundary marking. Here is the production setup we ship on kishansavaliya.com client projects.

RSC is production-ready for Magento today.

Through May 2026 we shipped four headless Magento storefronts on Next.js 14 App Router with React Server Components. Two replaced PWA Studio, one replaced Vue/Nuxt, one was greenfield. All four hit LCP under 1.5 s on field data within two weeks of launch.

The interesting part of RSC is not server rendering — Magento already had that. It is that the product grid ships zero client JavaScript while still being a React component.

PWA Studio ships the whole UI bundle to the browser then fetches GraphQL client-side. RSC inverts that: the server fetches GraphQL, renders HTML, and only the interactive pieces (cart, search-autocomplete, filters) load as client components. The product grid — the largest piece of every storefront — has zero client JS.

RSC primer for Magento developers

A React Server Component runs only on the server. It can be async, it can call fetch directly to GraphQL, and its output is serialized HTML plus a tiny props payload — never JavaScript.[1]

  • Server component — async allowed, can read cookies and call GraphQL. No useState, no useEffect.
  • Client component — opts in with 'use client'. Can use hooks and event handlers. Ships JavaScript.
  • Server action — function marked 'use server'. Callable from a client component via form action. The bridge for mutations like add-to-cart.

Next.js 14 App Router defaults every component to server unless you add 'use client'.[2] This is the opposite of PWA Studio and the reason RSC bundles are so much smaller.

The simplest possible Magento page

// app/(shop)/[category]/page.tsx
// This file is a server component by default. No 'use client' here.

import { cookies } from 'next/headers';
import { ProductGrid } from '@/components/product-grid';

export default async function CategoryPage({
  params,
}: {
  params: { category: string };
}) {
  const store = cookies().get('store')?.value ?? 'default';

  const res = await fetch(`${process.env.MAGENTO_URL}/graphql`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Store': store },
    body: JSON.stringify({
      query: PRODUCTS_QUERY,
      variables: { category: params.category },
    }),
    next: { revalidate: 60 },
  });

  const { data } = await res.json();
  return <ProductGrid items={data.products.items} />;
}

That file ships no client JavaScript for itself. Browser receives HTML with cards rendered, plus a small payload for nested client component hydration. PWA Studio on the same page: 340 KB gzipped JS downloads, parses, executes, then fetches GraphQL, then renders. RSC: 0 KB of grid JS, HTML arrives painted.

Problem 1: cart state needs a client boundary

The cart is per-session. Two customers on the same product page see different cart counts. That makes the cart panel uncacheable, uncomposable, and fundamentally client-side state.

Everything in Magento that can be server-rendered should be. Cart cannot, so cart is the boundary.

The pattern is to keep the cart panel and the minicart icon as client components, and let everything around them stay server-rendered. The 'use client' directive at the top of a file marks that file and its entire import graph as client-side. Place the boundary as deep as possible.

The cart client component

'use client';

// components/cart-panel.tsx
import { useState, useEffect } from 'react';
import { useCartStore } from '@/lib/cart-store';
import { addToCart, removeFromCart } from '@/actions/cart';

export function CartPanel() {
  const { items, cartId, hydrate } = useCartStore();
  const [isOpen, setIsOpen] = useState(false);

  // Hydrate from the cart cookie on first render.
  useEffect(() => {
    hydrate();
  }, [hydrate]);

  return (
    <aside data-open={isOpen}>
      <button onClick={() => setIsOpen(false)}>Close</button>
      {items.map((item) => (
        <form key={item.uid} action={removeFromCart}>
          <input type="hidden" name="cartId" value={cartId} />
          <input type="hidden" name="itemUid" value={item.uid} />
          <span>{item.product.name}</span>
          <button type="submit">Remove</button>
        </form>
      ))}
    </aside>
  );
}

Note the cart panel uses server actions (addToCart, removeFromCart) for the actual GraphQL mutations. The client component only owns the UI state — open/closed, optimistic updates, animation. The data fetching and mutation still happen on the server.

The server action for add-to-cart

'use server';

// actions/cart.ts
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';

export async function addToCart(formData: FormData) {
  const sku = formData.get('sku') as string;
  const qty = Number(formData.get('qty') ?? 1);
  const cartId = cookies().get('cart_id')?.value;

  const res = await fetch(`${process.env.MAGENTO_URL}/graphql`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${cookies().get('customer_token')?.value ?? ''}`,
    },
    body: JSON.stringify({
      query: ADD_TO_CART_MUTATION,
      variables: { cartId, sku, qty },
    }),
  });

  const { data, errors } = await res.json();
  if (errors) throw new Error(errors[0].message);

  revalidatePath('/cart');
  return data.addProductsToCart.cart;
}

The server action runs on the Next.js server, reads the cart cookie, hits Magento GraphQL, and returns the new cart payload. The client component renders the updated state without ever shipping the mutation code to the browser.

Problem 2: JWT refresh needs a server action bridge

Magento's customer authentication is JWT-based. The token lives in a cookie (or localStorage, depending on your storefront), expires after one hour by default, and must be refreshed by calling generateCustomerToken with the refresh token before expiry.[3]

The wrinkle: server components can read cookies via cookies() from next/headers, but they cannot write cookies. Writing cookies requires a server action, a route handler, or middleware. That means the refresh has to be triggered from somewhere — either a client component, middleware, or a server action.

The middleware-based refresh pattern

We use Next.js middleware to inspect the JWT on every request and refresh it transparently if it expires within five minutes.

// middleware.ts
import { NextResponse, NextRequest } from 'next/server';
import { jwtDecode } from 'jwt-decode';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('customer_token')?.value;
  if (!token) return NextResponse.next();

  const decoded = jwtDecode<{ exp: number }>(token);
  const expiresIn = decoded.exp * 1000 - Date.now();

  if (expiresIn > 5 * 60 * 1000) {
    return NextResponse.next();
  }

  // Token expires in under 5 minutes — refresh on the server.
  const res = await fetch(`${process.env.MAGENTO_URL}/graphql`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
    },
    body: JSON.stringify({
      query: `mutation { generateCustomerToken { token } }`,
    }),
  });

  const { data } = await res.json();
  const response = NextResponse.next();
  response.cookies.set('customer_token', data.generateCustomerToken.token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60,
  });
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

The middleware runs on every request, decodes the JWT (no validation — Magento does that), checks expiry, and silently rotates the token if needed. The customer never sees a re-login prompt and the server components downstream always have a fresh token to call GraphQL with.

The fallback: server action for explicit refresh

For edge cases where the customer manually triggers a refresh (clicking "Sign back in"), we expose a server action:

'use server';

// actions/auth.ts
import { cookies } from 'next/headers';

export async function refreshCustomerToken(email: string, password: string) {
  // Revoke old token first so Magento drops the session cleanly.
  await fetch(`${process.env.MAGENTO_URL}/graphql`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${cookies().get('customer_token')?.value ?? ''}`,
    },
    body: JSON.stringify({
      query: `mutation { revokeCustomerToken { result } }`,
    }),
  });

  // Generate a new token.
  const res = await fetch(`${process.env.MAGENTO_URL}/graphql`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: `mutation($email: String!, $password: String!) {
        generateCustomerToken(email: $email, password: $password) { token }
      }`,
      variables: { email, password },
    }),
  });

  const { data } = await res.json();
  cookies().set('customer_token', data.generateCustomerToken.token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60,
  });
}

That action is invokable from a form in a client component. The credentials never touch the browser-side JavaScript bundle because the action body runs on the server.

The GraphQL query shape that makes RSC fast

Server components fetch once, render, and discard — you can ask for everything in one round-trip without worrying about the bundle cost of the query string.

query ProductsForCategory($category: String!, $pageSize: Int = 24) {
  products(filter: { category_uid: { eq: $category } }, pageSize: $pageSize) {
    total_count
    items {
      uid sku name url_key
      price_range {
        minimum_price {
          final_price { value currency }
          regular_price { value currency }
        }
      }
      small_image { url label }
      stock_status
      ... on ConfigurableProduct {
        configurable_options { attribute_code values { uid label } }
      }
    }
  }
}

A client storefront would split this into three round-trips to keep the initial bundle small. On the server, the round-trip cost is zero — Next.js node and Magento PHP-FPM run in the same data center, GraphQL latency 8-15 ms.

Component → server or client → JS bytes shipped

ComponentServer or clientJS bytes shipped (gzipped)
Category page layoutServer0 KB
Product gridServer0 KB
Product cardServer0 KB
Filter sidebarClient~18 KB
Search autocompleteClient~12 KB
PDP image galleryClient~22 KB
PDP add-to-cart formServer (with action)~3 KB (form runtime)
Cart panelClient~16 KB
Minicart iconClient~4 KB
Header / footerServer0 KB
Next.js runtimeRequired~38 KB
Total first-load JS~113 KB

For comparison, PWA Studio first-load JS on the same store sits at ~340 KB gzipped. The Vue/Nuxt build the same store ran before was ~210 KB. The RSC build is the smallest of the three by a wide margin, and the gap widens on the category and listing pages where RSC ships nothing for the grid.

Bundle math: PWA Studio vs RSC

Real numbers from one of the four migrations — a 12,000 SKU Magento 2.4.9 store with Adyen, B2B quote workflow, and multi-store.

  • PWA Studio: first-load JS 342 KB · TTI 4.2 s · LCP 2.8 s · INP 240 ms · Lighthouse 64
  • RSC build: first-load JS 113 KB (67% smaller) · TTI 1.4 s · LCP 1.1 s · INP 90 ms · Lighthouse 94

Mobile conversion rose from 1.8% to 2.4% at 30 days post-launch. Bounce rate on category pages dropped from 38% to 22%. Revenue per visitor on mobile rose 31%.

PWA Studio shipped React to the browser. RSC ships HTML to the browser. The difference shows up in every Lighthouse run and every Google ranking signal.

When RSC is the wrong answer

RSC is not free. The Next.js node process needs to run somewhere — operational overhead a Magento-only stack does not have. Three cases where we recommend against migrating:

  • Hyvä already works. If your store is on Hyvä 1.3 with sub-1.5 s LCP and Lighthouse 90+, the RSC migration buys 5-10% and costs $40-80k in engineering.
  • Heavy B2B with deep filtering. If 80% of visitors immediately filter, you re-fetch on every filter change. Client-side filtering on a pre-loaded subset can be faster.
  • Already on PWA Studio 12+. React 18 streaming improvements narrow the gap. Migration ROI is weaker than from PWA Studio 9 or 10.

The gotchas we hit on production migrations

Cache poisoning via missing store header

Next.js caches server component fetches by URL plus headers. If you forget to vary on the Magento store header, store A's customer sees store B's prices for 60 seconds. Add the store to the cache key explicitly:

const res = await fetch(url, {
  headers: { Store: store },
  next: {
    revalidate: 60,
    tags: [`products:${store}:${category}`],
  },
});

Streaming and slow Magento queries

A category with 24 products and three filter facets takes 80-180 ms in Magento GraphQL on a tuned store. Wrap the slow part in <Suspense> so the rest of the page streams first:

import { Suspense } from 'react';

export default function CategoryPage() {
  return (
    <>
      <CategoryHeader />
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
    </>
  );
}

The header and footer paint at TTFB. The grid streams in when Magento responds. LCP measures the header in this layout, so it drops to 200-400 ms.

FAQ

Does RSC work with Magento Open Source 2.4.4?

Yes. Magento GraphQL has been stable since 2.4.0 and the schema we use (products, cart, customer, checkout) is identical across Magento 2.4.4 — 2.4.9. We have shipped RSC on a 2.4.6 store and a 2.4.9 store using the same Next.js codebase.

Can I run RSC alongside Hyvä?

Yes, but they solve different problems. Hyvä is a server-rendered Magento theme with Alpine.js. RSC is a separate Next.js app on GraphQL. Most teams pick one.

What about SEO?

RSC is better for SEO than PWA Studio because HTML is server-rendered. Googlebot sees a painted page, not a JS shell. We measured 18-30% organic lift after Google re-crawled.

Does Adobe Commerce B2B work with RSC?

Yes. The B2B schema (company, quote, requisition list) is fully supported in server components. The requisition list editor is highly interactive and ends up as a ~40 KB client component. Plan for it.

What about the checkout?

Checkout is the hardest piece because every step (shipping, payment) is a state transition. We mark the entire checkout flow as a client component tree, but each GraphQL mutation goes through a server action so credentials never enter the browser bundle.

How do I migrate from PWA Studio incrementally?

Next.js multi-zones — run Next.js for category and PDP, keep PWA Studio for cart and checkout, route by URL. The URL boundary needs to be clean and the cookie domain shared.

Citations

  1. React Server Components — react.dev
  2. Next.js App Router — Server Components
  3. Magento GraphQL — generateCustomerToken mutation
Considering a headless rebuild on Magento?

I run a fixed-scope RSC discovery sprint that benchmarks your current storefront, models the bundle math, and ships a working RSC prototype against your live Magento 2.4.4 — 2.4.9 instance. Fixed quote from $499 audit · $2,499 sprint · ~100h @ $25/hr. See hire me.