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.
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.
| Concern | Hyvä (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 SKUs | Zero — fully dynamic | Hours, if you SSG everything — use ISR on-demand instead |
| Cart accuracy | Server-authoritative every request | Server-authoritative via server action |
| SEO crawl budget | One Magento hit per crawl | One Vercel edge hit per crawl |
| Cost at 1M sessions/mo | $240 — $600 (one host) | $220 — $700 (Magento + Vercel Pro) |
| When to pick it | B2B, configurable-heavy, small team | D2C, 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-cookieCreate .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 runtimeTrap: 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.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.
4. Magento JWT auth in an HTTP-only cookie
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_urlfrom the GraphQL response and emit it viagenerateMetadata'salternates.canonical. Mismatch with the Magento backend canonical confuses Search Console. - Hreflang: set
alternates.languagesusingstoreConfig.base_urlper 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/imagewith the Magento media URL, Magento domain inimages.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 --prodFor 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.
Related reading
References
- Next.js project documentation, App Router — Data Fetching, Caching, and Revalidating — nextjs.org/docs/app/building-your-application/data-fetching.
- Next.js project documentation, Incremental Static Regeneration (ISR) — nextjs.org/docs/app/building-your-application/data-fetching/incremental-static-regeneration.
- Adobe Commerce DevDocs, GraphQL — Customer authentication tokens — developer.adobe.com/commerce/webapi/graphql. Reference for
generateCustomerToken,revokeCustomerToken, and theAuthorization: Bearerheader contract. - Adobe Commerce DevDocs, GraphQL — Quote (cart) workflow — covers
createEmptyCart,addProductsToCart,mergeCarts,setShippingAddressesOnCart,setPaymentMethodOnCart, andplaceOrder. - 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.
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.