Chat on WhatsApp
Headless & Architecture 14 min read

Magento Next.js Storefront — Build It From Zero (Vercel-Deployable)

Most Magento + Next.js tutorials stop at fetching a product list. They skip authentication, cart hydration, ISR cache invalidation, and the SEO checklist for what to render server-side. This post walks through a complete `npx create-next-app` storefront against a Magento 2.4.4 — 2.4.9 GraphQL endpoint, deploy-ready on Vercel. Eight sections: project bootstrap, GraphQL client without Apollo, ISR for category pages, JWT auth via `generateCustomerToken` in an HTTP-only cookie, guest-and-customer cart hydration, server actions for `placeOrder`, the SEO checklist, and the production deploy. Real TypeScript, real GraphQL, and the traps that bite when the demo hits real customer traffic.

Magento Next.js Storefront — Build It From Zero (Vercel-Deployable)

A Magento Next.js storefront is a React + TypeScript frontend that calls Magento 2.4.4 — 2.4.9's public GraphQL endpoint, deploys to Vercel, and uses Next.js App Router features — server components, ISR, server actions — to keep the JavaScript bundle small and the SEO surface server-rendered. By the end of this post you have npx create-next-app talking to a real Magento store, with auth, cart, and checkout that survive a security review.

The architecture in one paragraph

Every page is a server component that fetches GraphQL on the server using a fetch-based client (no Apollo, no SWR on the server). Category and product pages cache with ISR (revalidate: 60); cart and checkout are dynamic. The customer JWT lives in an HTTP-only cookie set by a server action — never exposed to client JavaScript. The cart is a single cartId string kept in localStorage for guests and migrated at login.[1] The whole thing deploys to Vercel with one environment variable.

Headless Magento is mostly where you put the cart ID and the JWT. Get those right and the rest is GraphQL.

Hyvä SSR vs Next.js ISR — when each wins

Pin this table before you commit to the architecture. The wrong choice here costs three to six weeks of refactor when launch traffic shows up.

ConcernHyvä (SSR + Alpine)Next.js ISR (this post)
Time-to-first-byte (cold)180 — 400 ms (Magento + PHP-FPM)30 — 80 ms (Vercel edge, cached HTML)
Time-to-interactive (PDP)~1.4 s on mid-range phone~1.1 s on mid-range phone
Build time, 50k SKUsZero — fully dynamicHours, if you SSG everything — use ISR on-demand instead
Cart accuracyServer-authoritative every requestServer-authoritative via server action
SEO crawl budgetOne Magento hit per crawlOne Vercel edge hit per crawl
Cost at 1M sessions/mo$240 — $600 (one host)$220 — $700 (Magento + Vercel Pro)
When to pick itB2B, configurable-heavy, small teamD2C, marketing-heavy, multi-region, large team

For most stores under $5M/year, Hyvä wins on cost and complexity. For multi-region D2C brands shipping marketing pages weekly, Next.js wins on developer velocity and edge-cached HTML. The rest of this post assumes the call is made.

1. Bootstrap the project

Bootstrap with create-next-app using App Router, TypeScript, and Tailwind — the three defaults Next.js 14 already nudges you toward. No starter template — every starter I have audited drags in a packaging choice you rip out by week two.

npx create-next-app@14 magento-storefront \
  --typescript \
  --tailwind \
  --app \
  --src-dir \
  --import-alias "@/*"

cd magento-storefront
npm install graphql graphql-request js-cookie
npm install -D @types/js-cookie

Create .env.local with the one variable that matters. The public-prefixed URL is safe to expose; admin integration tokens are not — never put them in NEXT_PUBLIC_*.

# .env.local
NEXT_PUBLIC_MAGENTO_GRAPHQL_URL=https://shop.example.com/graphql
MAGENTO_STORE_CODE=default
# DO NOT add admin tokens here — customer JWT comes from generateCustomerToken at runtime

Trap: Magento's GraphQL endpoint expects the Store header for multi-store setups. Forgetting it returns the default store regardless of locale, silently breaking hreflang alignment.

2. A GraphQL client without the Apollo bloat

Use graphql-request as the runtime client — a 4 KB wrapper over fetch with TypeScript generics. Apollo is 40 KB gzipped; urql is 15 KB. On a marketing-led storefront the bundle matters more than the cache layer, because most data comes from server components anyway.

// src/lib/graphql.ts
import { GraphQLClient, Variables } from 'graphql-request';

const endpoint = process.env.NEXT_PUBLIC_MAGENTO_GRAPHQL_URL!;

export function magentoClient(token?: string, storeCode?: string) {
  return new GraphQLClient(endpoint, {
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...(storeCode ? { Store: storeCode } : {}),
    },
    fetch,
  });
}

export async function magentoQuery(
  query: string,
  variables: Variables = {},
  opts: { token?: string; storeCode?: string; revalidate?: number } = {}
): Promise {
  const res = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
      ...(opts.storeCode ? { Store: opts.storeCode } : {}),
    },
    body: JSON.stringify({ query, variables }),
    next: { revalidate: opts.revalidate ?? 0 },
  });
  const json = await res.json();
  if (json.errors) throw new Error(json.errors[0].message);
  return json.data as T;
}

Trap: the next: { revalidate } option on fetch is the only way to tag a request for ISR — passing it through graphql-request's GraphQLClient does not work because that wrapper does not forward Next.js-specific fetch options. Use raw fetch for cached server queries; reserve GraphQLClient for client-component mutations.

3. ISR for category pages

Category pages re-render on a 60-second interval using revalidate: 60 on the fetch call. ISR — Incremental Static Regeneration — serves cached HTML instantly and rebuilds in the background after the window expires.[2] For a 5k-SKU catalog, this turns a 400 ms PHP-FPM render into a 40 ms Vercel edge hit, keeping price and stock at most 60 seconds stale.

query CategoryProducts($url_key: String!) {
  categoryList(filters: { url_key: { eq: $url_key } }) {
    name
    description
    meta_title
    meta_description
    canonical_url
    products(pageSize: 24, currentPage: 1) {
      total_count
      items {
        sku
        name
        url_key
        small_image { url label }
        price_range {
          minimum_price {
            final_price { value currency }
          }
        }
      }
    }
  }
}
// src/app/c/[slug]/page.tsx
import { magentoQuery } from '@/lib/graphql';
import { CATEGORY_PRODUCTS } from '@/lib/queries/category';
import { notFound } from 'next/navigation';
import Link from 'next/link';

type Props = { params: { slug: string } };

export async function generateMetadata({ params }: Props) {
  const data = await magentoQuery(
    CATEGORY_PRODUCTS,
    { url_key: params.slug },
    { revalidate: 60 }
  );
  const cat = data.categoryList?.[0];
  if (!cat) return {};
  return {
    title: cat.meta_title ?? cat.name,
    description: cat.meta_description ?? undefined,
    alternates: { canonical: cat.canonical_url ?? `/c/${params.slug}` },
  };
}

export default async function CategoryPage({ params }: Props) {
  const data = await magentoQuery(
    CATEGORY_PRODUCTS,
    { url_key: params.slug },
    { revalidate: 60 }
  );
  const cat = data.categoryList?.[0];
  if (!cat) notFound();

  return (
    

{cat.name}

{cat.products.items.map((p) => ( {p.small_image.label}

{p.name}

{p.price_range.minimum_price.final_price.value.toFixed(2)} {p.price_range.minimum_price.final_price.currency}

))}
); }

Trap: generateMetadata and the page render fetch the same query — Next.js dedupes identical fetches in one request, so it is one call, not two. Break dedupe with different variables or fetch options and you double the GraphQL load on Magento.

Authenticate via generateCustomerToken, set the returned JWT in an HTTP-only cookie from a server action, and read the cookie on the server for every authenticated GraphQL call. Storing the JWT in localStorage is the most common mistake in headless Magento tutorials — it leaves the token readable by any third-party script on the origin, a documented XSS vector.

mutation GenerateCustomerToken($email: String!, $password: String!) {
  generateCustomerToken(email: $email, password: $password) {
    token
  }
}

mutation RevokeCustomerToken {
  revokeCustomerToken { result }
}
// src/app/login/actions.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { magentoQuery } from '@/lib/graphql';
import { GENERATE_TOKEN } from '@/lib/queries/auth';

export async function login(formData: FormData) {
  const email = String(formData.get('email') ?? '');
  const password = String(formData.get('password') ?? '');

  const data = await magentoQuery<{ generateCustomerToken: { token: string } }>(
    GENERATE_TOKEN,
    { email, password }
  );

  const token = data.generateCustomerToken.token;

  cookies().set('mage_jwt', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    // Magento default JWT lifetime is 1 hour for customer tokens (admin config)
    maxAge: 60 * 60,
  });

  redirect('/account');
}

export async function logout() {
  const token = cookies().get('mage_jwt')?.value;
  if (token) {
    try {
      await magentoQuery('mutation { revokeCustomerToken { result } }', {}, { token });
    } catch {
      // Token may already be expired — drop the cookie anyway
    }
  }
  cookies().delete('mage_jwt');
  redirect('/');
}

The form is a plain <form action={login}> — Next.js wires the server action automatically. No useState, no onSubmit, no client bundle.

Trap: Magento's default customer JWT lifetime is 1 hour. Either match the cookie maxAge to that, or read the JWT exp claim and set maxAge dynamically. Mismatch and authenticated routes throw on the 61st minute. Also: secure: true is HTTPS-only — fine for Vercel and required in production; browsers exempt localhost in dev.

5. Cart hydration — guest and customer

Treat the cart as one string — the Magento cartId — kept in localStorage for guests and merged into the customer cart at login via mergeCarts. The customer cart is auto-created on first authenticated GraphQL call, and the guest reference is dropped after merge.

mutation CreateEmptyCart { createEmptyCart }

mutation AddProductsToCart($cartId: String!, $sku: String!, $qty: Float!) {
  addProductsToCart(
    cartId: $cartId
    cartItems: [{ sku: $sku, quantity: $qty }]
  ) {
    cart { id total_quantity }
    user_errors { code message }
  }
}

mutation MergeCarts($source: String!, $dest: String!) {
  mergeCarts(source_cart_id: $source, destination_cart_id: $dest) {
    id
    total_quantity
  }
}
// src/lib/cart.ts
'use client';
import { magentoQuery } from '@/lib/graphql';
import { CREATE_CART, ADD_TO_CART } from '@/lib/queries/cart';

const KEY = 'mage_guest_cart_id';

export async function ensureCart(): Promise {
  let id = typeof window !== 'undefined' ? localStorage.getItem(KEY) : null;
  if (id) return id;
  const data = await magentoQuery<{ createEmptyCart: string }>(CREATE_CART);
  id = data.createEmptyCart;
  localStorage.setItem(KEY, id);
  return id;
}

export async function addToCart(sku: string, qty = 1) {
  const cartId = await ensureCart();
  const data = await magentoQuery(ADD_TO_CART, { cartId, sku, qty });
  if (data.addProductsToCart.user_errors.length) {
    throw new Error(data.addProductsToCart.user_errors[0].message);
  }
  return data.addProductsToCart.cart;
}

export function clearGuestCart() {
  localStorage.removeItem(KEY);
}
// src/app/login/actions.ts — extend login() to merge cart after token issue
async function mergeGuestCart(token: string, guestCartId: string) {
  const dest = await magentoQuery<{ customerCart: { id: string } }>(
    'query { customerCart { id } }', {}, { token }
  );
  await magentoQuery(
    'mutation M($s:String!,$d:String!){mergeCarts(source_cart_id:$s,destination_cart_id:$d){id}}',
    { s: guestCartId, d: dest.customerCart.id }, { token }
  );
}

Trap: createEmptyCart can be called without a token — the returned cartId survives for checkout/cart/delete_quote_after (default 30 days). Do not call it on every page load; orphaned guest carts pile up in quote and slow indexer cron. Lazy-create on first add-to-cart instead.

6. Checkout — placeOrder as a server action

Run placeOrder inside a server action so the customer JWT and cart ID never reach the client. Server actions execute on the Vercel function — the JWT stays on the server side of the network boundary; the only data the browser sees is the order increment_id.[3]

mutation SetShippingAddress($cartId: String!, $input: ShippingAddressInput!) {
  setShippingAddressesOnCart(
    input: { cart_id: $cartId, shipping_addresses: [{ address: $input }] }
  ) { cart { id } }
}

mutation SetPayment($cartId: String!, $method: String!) {
  setPaymentMethodOnCart(
    input: { cart_id: $cartId, payment_method: { code: $method } }
  ) { cart { id } }
}

mutation PlaceOrder($cartId: String!) {
  placeOrder(input: { cart_id: $cartId }) {
    order { order_number }
  }
}
// src/app/checkout/actions.ts
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { magentoQuery } from '@/lib/graphql';
import { SET_SHIPPING, SET_PAYMENT, PLACE_ORDER } from '@/lib/queries/checkout';

export async function placeOrder(formData: FormData) {
  const token = cookies().get('mage_jwt')?.value;
  const cartId = String(formData.get('cartId') ?? '');

  await magentoQuery(SET_SHIPPING, {
    cartId,
    input: {
      firstname: String(formData.get('firstname')),
      lastname: String(formData.get('lastname')),
      street: [String(formData.get('street'))],
      city: String(formData.get('city')),
      region: String(formData.get('region')),
      postcode: String(formData.get('postcode')),
      country_code: String(formData.get('country_code')),
      telephone: String(formData.get('telephone')),
    },
  }, { token });

  await magentoQuery(SET_PAYMENT, { cartId, method: 'checkmo' }, { token });

  const data = await magentoQuery<{ placeOrder: { order: { order_number: string } } }>(
    PLACE_ORDER, { cartId }, { token }
  );

  // Guest cart is consumed by placeOrder — drop the local reference
  cookies().delete('mage_guest_cart_id');
  redirect(`/checkout/success?order=${data.placeOrder.order.order_number}`);
}

Trap: guest placeOrder requires setGuestEmailOnCart first — call it before setShippingAddressesOnCart when no JWT is present. Forgetting it returns a misleading The cart isn't active error.

7. The SEO checklist — what renders where

The rule for a headless storefront to rank: anything Googlebot needs must come from a server component, and canonicals must agree with the Magento backend.

  • H1, product name, price, description: server component, rendered from GraphQL on first byte.
  • Canonical URL: read canonical_url from the GraphQL response and emit it via generateMetadata's alternates.canonical. Mismatch with the Magento backend canonical confuses Search Console.
  • Hreflang: set alternates.languages using storeConfig.base_url per store code from Magento. Sourced from the backend so the storefronts cannot drift.
  • Structured data (Product, BreadcrumbList, FAQPage): server-rendered <script type="application/ld+json">, never injected by a client effect.
  • Cart, checkout, account: dynamic rendering. Robots disallows the paths or pages emit robots: 'noindex'.
  • Images: next/image with the Magento media URL, Magento domain in images.remotePatterns, width/height from GraphQL to avoid CLS.
  • Sitemap: generate from Magento's existing output and reference from robots.txt. Next.js does not need its own when the catalog lives in Magento.

The Magento media domain has to be allow-listed for the Next.js image optimizer. Add it once to next.config.js:

{
  "images": {
    "remotePatterns": [
      { "protocol": "https", "hostname": "shop.example.com", "pathname": "/media/**" }
    ]
  }
}
Headless Magento does not get a different SEO playbook — it gets the same one, with the discipline that frameworks default to client-side unless you ask otherwise.

8. Deploy to Vercel

The deploy is one command and one environment variable. vercel auto-detects Next.js, builds in ~90 seconds, and gives a preview URL per push. For production, the only choices are deploy region and whether you need on-demand ISR invalidation.

npm i -g vercel
vercel login
vercel link
vercel env add NEXT_PUBLIC_MAGENTO_GRAPHQL_URL production
# paste: https://shop.example.com/graphql
vercel env add MAGENTO_STORE_CODE production
# paste: default
vercel --prod

For on-demand revalidation — admin saves a product, category page refreshes in seconds — add a route handler that calls revalidatePath() and wire a Magento catalog_product_save_after observer to POST with a shared secret.

// src/app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-revalidate-secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }
  const { paths } = (await req.json()) as { paths: string[] };
  for (const p of paths) revalidatePath(p);
  return NextResponse.json({ ok: true, revalidated: paths });
}

Trap: revalidatePath only hits the named path. For a product save, invalidate both /p/[url_key] and every category that contains it — or the PLP card stays stale up to 60 seconds.

Where Next.js is the wrong choice

Hyvä SSR beats this stack on three counts: heavily configurable B2B catalogs (dependent option resolution doubles the Next.js build); multi-step quote workflows across days (Magewire handles persisted state more cleanly); small teams (one hosting surface beats two). The storefront here earns its keep when marketing ships pages weekly, multi-region routing is non-negotiable, and there is a React + TypeScript specialist on staff.

FAQ

Can I run this against Magento Open Source, or do I need Adobe Commerce?

Magento Open Source 2.4.4 — 2.4.9 ships the same GraphQL schema needed for this storefront. Adobe Commerce adds B2B-specific types (company accounts, requisition lists, negotiable quotes) but the core catalog, cart, customer, and checkout schema is identical.

Why not Apollo Client?

Apollo is 40 KB gzipped before configuration, ships its own cache layer that duplicates Next.js's fetch cache, and pushes you toward client components for data fetching. Reach for Apollo only if you need its subscription model or normalized cache features — a typical storefront does not.

Does ISR work for personalized content?

No — ISR is per-path, not per-customer. Personalized blocks (welcome message, recommendations) belong in a client component that hydrates after the cached HTML loads. Same pattern as Magento FPC + customer-data — the shell is cached, the personalization is fetched.

What happens when the JWT expires mid-session?

The next authenticated GraphQL call returns a 401 with The current customer isn't authorized. Catch it in a server-side error boundary, drop the cookie, and redirect to /login. Do not silently refresh the token client-side — that reopens the XSS surface this architecture is designed to close.

Can I use this with Hyvä on the backend?

Yes — Hyvä is a Magento theme, not a different Magento. Both ship the same GraphQL endpoint. You can run Hyvä for the legacy URLs (/customer/*, /sales/order) while serving marketing routes from Next.js, sharing the same backend data.

References

  1. Next.js project documentation, App Router — Data Fetching, Caching, and Revalidating — nextjs.org/docs/app/building-your-application/data-fetching.
  2. Next.js project documentation, Incremental Static Regeneration (ISR) — nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration.
  3. Adobe Commerce DevDocs, GraphQL — Customer authentication tokens — developer.adobe.com/commerce/webapi/graphql. Reference for generateCustomerToken, revokeCustomerToken, and the Authorization: Bearer header contract.
  4. Adobe Commerce DevDocs, GraphQL — Quote (cart) workflow — covers createEmptyCart, addProductsToCart, mergeCarts, setShippingAddressesOnCart, setPaymentMethodOnCart, and placeOrder.
  5. Production headless Magento engagements via kishansavaliya.com, 2024 — 2026 — architecture and traps validated across four Vercel-deployed storefronts on Magento 2.4.4 — 2.4.9.
Headless storefront on the roadmap?

I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer shipping headless Next.js storefronts against Magento 2.4.4 — 2.4.9 — JWT-cookie auth, ISR catalog, server-action checkout, Vercel deploy. Fixed-scope from $499 audit · $2,499 sprint · ~100h @ $25/hr for a full storefront. See headless Magento or hire me.