← Back to blog

Headless Magento 2 with Next.js: Storefront Architecture

Rodrigo Biazzi

Next.js is a common choice for a headless Magento storefront because it gives you server rendering, static generation and caching in one framework, and it is easy to hire for. This article shows how the main pieces fit together with Magento 2.4.8 GraphQL: a small GraphQL client, URL resolution for Magento's SEO URLs, product and category pages, the cart, and cache invalidation. It uses the Next.js App Router with plain JavaScript to keep the examples short. It is not a full storefront, but it covers the decisions that shape one.

If you have not decided on headless yet, read PWA Studio vs custom headless first. The cost of building and running a second application is the real decision, not the framework.

Architecture

  • Magento runs as usual, with Varnish in front of /graphql. The storefront theme is not used by customers anymore, but the admin and APIs are.
  • Next.js renders pages on the server and calls Magento GraphQL from server components. The browser talks to Next.js, not directly to Magento, for most reads.
  • Cart and customer state is identified by a cart ID and an optional customer token stored in HTTP only cookies set by Next.js.

Calling Magento from the server side has two advantages. You avoid CORS for most requests, because Magento does not send CORS headers by default, and you keep tokens out of client side JavaScript.

A minimal GraphQL client

You do not need Apollo for a server rendered storefront. fetch with the Next.js cache options is enough. Read queries go as GET so Varnish can cache them, mutations go as POST.

// lib/magento.js
const ENDPOINT = process.env.MAGENTO_GRAPHQL_URL;

export async function magentoQuery(query, variables = {}, options = {}) {
  const url = new URL(ENDPOINT);
  url.searchParams.set('query', query);
  url.searchParams.set('variables', JSON.stringify(variables));

  const response = await fetch(url, {
    headers: { Store: options.store || 'default' },
    next: { revalidate: options.revalidate ?? 300, tags: options.tags || [] }
  });
  return parseResponse(response);
}

export async function magentoMutation(query, variables = {}, options = {}) {
  const headers = {
    'Content-Type': 'application/json',
    Store: options.store || 'default'
  };
  if (options.token) {
    headers.Authorization = 'Bearer ' + options.token;
  }

  const response = await fetch(ENDPOINT, {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
    cache: 'no-store'
  });
  return parseResponse(response);
}

async function parseResponse(response) {
  const json = await response.json();
  if (json.errors && json.errors.length) {
    throw new Error(json.errors.map(e => e.message).join(', '));
  }
  return json.data;
}

Two things to notice. The next.tags option lets you invalidate cached data by tag later, which is how product updates reach the storefront. And customer specific queries must not use the shared cache. Pass cache: 'no-store' for anything that uses a token.

GraphQL errors from Magento come back with HTTP 200 and an errors array. Always check it. Some errors are expected, for example an expired cart, and should be handled instead of thrown.

Resolving Magento URLs

Magento owns the URL structure through URL rewrites. Product and category URLs are configured in the admin and can change. The storefront should not guess. Use a catch all route and ask Magento what a path is with the route query.

// app/[...slug]/page.js
import { notFound, permanentRedirect, redirect } from 'next/navigation';
import { magentoQuery } from '@/lib/magento';
import ProductPage from '@/components/ProductPage';
import CategoryPage from '@/components/CategoryPage';

const ROUTE_QUERY = `
query Route($url: String!) {
  route(url: $url) {
    __typename
    relative_url
    redirect_code
    ... on CategoryInterface { uid name }
    ... on ProductInterface { uid sku name }
  }
}`;

export default async function Page({ params }) {
  const { slug } = await params;
  const path = slug.join('/');
  const data = await magentoQuery(ROUTE_QUERY, { url: path }, { tags: ['route'] });
  const route = data.route;

  if (!route) {
    notFound();
  }
  if (route.redirect_code === 301) {
    permanentRedirect('/' + route.relative_url);
  }
  if (route.redirect_code === 302) {
    redirect('/' + route.relative_url);
  }
  if (route.__typename.endsWith('Product')) {
    return <ProductPage sku={route.sku} />;
  }
  if (route.__typename === 'CategoryTree') {
    return <CategoryPage uid={route.uid} />;
  }
  notFound();
}

The file uses JSX, so it needs the JSX support Next.js provides by default. Product types come back as SimpleProduct, ConfigurableProduct, BundleProduct and so on. Categories resolve to CategoryTree. CMS pages resolve to CmsPage, which you can add the same way. Respecting Magento's redirect codes keeps old URLs working after a migration, which matters a lot for SEO.

Product and category pages

Fetch only what the page renders. A product page needs much more than a product tile on a category page, so keep two fragments.

query ProductBySku($sku: String!) {
  products(filter: { sku: { eq: $sku } }) {
    items {
      uid
      sku
      name
      stock_status
      description { html }
      media_gallery { url label }
      price_range {
        minimum_price {
          final_price { value currency }
          regular_price { value currency }
        }
      }
      ... on ConfigurableProduct {
        configurable_options { attribute_code label values { uid label } }
      }
    }
  }
}

Tag the request with something specific, for example product-24-MB01 built from the SKU, so you can revalidate just that product later. For category listings use the products query with filter: { category_uid: { eq: $uid } }, plus aggregations for layered navigation and page_info for pagination.

The description is HTML from the admin, possibly with Page Builder markup. Render it as HTML and sanitize it if editors can paste arbitrary content. If the store relies heavily on Page Builder, budget time for rendering and styling its content types in the new frontend.

Images

Media URLs point to the Magento host. If you use next/image, add the Magento media host to images.remotePatterns in next.config.js. Magento already resizes and caches product images, so decide whether Next.js or Magento owns resizing. Doing both wastes CPU and storage.

Cart and checkout

Cart operations are mutations. Use Next.js server actions or route handlers so the cart ID and token stay in HTTP only cookies.

// app/actions/cart.js
'use server';

import { cookies } from 'next/headers';
import { magentoMutation } from '@/lib/magento';

export async function addToCart(sku, quantity) {
  const cookieStore = await cookies();
  let cartId = cookieStore.get('cart_id')?.value;
  const token = cookieStore.get('customer_token')?.value;

  if (!cartId) {
    const created = await magentoMutation('mutation { createGuestCart { cart { id } } }');
    cartId = created.createGuestCart.cart.id;
    cookieStore.set('cart_id', cartId, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
  }

  const data = await magentoMutation(
    `mutation Add($cartId: String!, $items: [CartItemInput!]!) {
      addProductsToCart(cartId: $cartId, cartItems: $items) {
        cart { total_quantity }
        user_errors { code message }
      }
    }`,
    { cartId, items: [{ sku, quantity }] },
    { token }
  );
  return data.addProductsToCart;
}

For a logged in customer, get the cart ID from the customerCart query instead of creating a guest cart, and merge any guest cart after login with mergeCarts. The full sequence from cart to order is in GraphQL cart and checkout mutations, and login and tokens are covered in GraphQL customer authentication.

Payment is where headless projects slow down. Each provider has its own frontend SDK and its own way to pass a payment token to Magento through setPaymentMethodOnCart. Check that your provider documents a headless or GraphQL flow before you commit to a launch date.

Keeping data fresh

With revalidate: 300, a price change shows up within five minutes. For many stores that is fine for content but not for price and stock. Use tag based revalidation for those. A route handler accepts a signed request and invalidates tags.

// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache';

export async function POST(request) {
  const secret = request.headers.get('x-revalidate-secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return Response.json({ ok: false }, { status: 401 });
  }
  const { tags } = await request.json();
  for (const tag of tags || []) {
    revalidateTag(tag);
  }
  return Response.json({ ok: true });
}

On the Magento side, a small module publishes product save and stock change events to a queue, and a consumer calls this endpoint with the affected tags. Do not call the endpoint synchronously from a save observer, because an admin save or an import would then wait on HTTP calls to the frontend. The queue pattern is described in RabbitMQ consumers in Magento 2.

SEO checklist for the frontend

  • Render product and category content on the server.
  • Use Magento's meta title, meta description and canonical URL fields from GraphQL.
  • Return a real 404 status for unknown paths and real 301 redirects from URL rewrites.
  • Output product structured data with price and availability from the same response the page uses.
  • Generate a sitemap from Magento data, or proxy Magento's sitemap if it uses the same URLs.

Get these right before launch. They are hard to notice in testing and expensive to fix once rankings drop.

FAQ

Should Next.js call Magento GraphQL from the browser or the server?

Mostly from the server. It avoids CORS, keeps customer tokens in HTTP only cookies, and lets Next.js cache responses. Client side calls are fine for small interactive parts if CORS is configured.

How do I map Magento URLs to Next.js pages?

Use a catch all route and the GraphQL route query. It returns the entity type, its identifiers and any redirect code from Magento's URL rewrites.

How do product updates reach a cached Next.js page?

Tag fetches with product specific tags and call revalidateTag from a route handler. Magento triggers it asynchronously through a queue consumer after saves or stock changes.

Can I keep the Magento checkout and only build the catalog in Next.js?

It is possible, but the cart must then be shared between both, which means the same domain or a careful session handoff. Most teams build checkout in the frontend or use a hosted payment page.

I am available for freelance Magento and Adobe Commerce work, including headless builds. If you need help with this, get in touch.

Related articles